7thSage again. Welcome back. This time we’ll be expanding upon pathfinding from earlier in the series and creating some different range options for our attacks. While some attacks might be a simple distance like our movement, other attacks might use a pattern such as a line or a cone. I did break from the original lessons on the specific formulas used in the different range options, but in the process I tried to add some additional functionality, and added a few new shapes. I hope you like them.
BoardCreator
There are a few changes we need to make in the “BoardCreator.gd” script. First, lets make a small fix in the RangeSearch() function. In the loop we auto add the “start” tile with “retValue.append(start)”. However, I think it would be beneficial if we were able to send it through our filter like any other tile, as we’ll use in some of our ranges later. Change the line to the following lines:
if addTile.call(start, start): retValue.append(start)
The next change to BoardCreator is a method to keep track of what the min and max points are on the board so we can stop our range at the edge of the board instead of the edge of the range that may be in the thousands or higher. I’m only implementing a very basic version of this here, so you may want to make this more robust in the future. Here we’ll creating some variables to store the min and max, and in the LoadMap() and LoadMapJSON() functions we’ll check if the range needs to expand every time we add a tile. The limitation is that if anything changes the board, or if a map is created outside of those functions, the min and max values will not be updated.
At the beginning of the file with the other variables we’ll add the following to keep track
var _min = Vector2i(999999, 999999) var _max = Vector2i(-999999, -999999) var min:Vector2i: get: return _min var max:Vector2i: get: return _max
In the Clear() function, we’ll add these lines to set the _min and _max values to something well above what their range should be, and as soon as we add our first tile, the numbers will begin to trend towards their final values.
_min = Vector2i(999999, 999999) _max = Vector2i(-999999, -999999)
Next, feel free to implement one or both of the load functions in the next two snippets, depending on what you chose to use as your map format. In the LoadMap() function, we’ll update the for loop to look like this
for i in range(size): var save_x = save_game.get_8() var save_z = save_game.get_8() var save_height = save_game.get_8() var t: Tile = _Create() t.Load(Vector2i(save_x, save_z) , save_height) tiles[Vector2i(t.pos.x,t.pos.y)] = t _min.x = min(_min.x, t.pos.x) _min.y = min(_min.y, t.pos.y) _max.x = max(_max.x, t.pos.x) _max.y = max(_max.y, t.pos.y)
For LoadMapJSON() we’ll update the for loop to look like this
for mtile in data["tiles"]: var t: Tile = _Create() t.Load(Vector2(mtile["pos_x"], mtile["pos_z"]) , mtile["height"]) tiles[Vector2i(t.pos.x,t.pos.y)] = t _min.x = min(_min.x, t.pos.x) _min.y = min(_min.y, t.pos.y) _max.x = max(_max.x, t.pos.x) _max.y = max(_max.y, t.pos.y)
Directions
Next, let’s add a couple new functions to “Directions.gd”. One to return a vector from a direction, and another to get a direction from a single vector.
static func ToVector(d: Dirs): #SOUTH,EAST,NORTH,WEST var _dirs = [Vector2i(0,1), Vector2i(1,0), Vector2i(0,-1), Vector2i(-1,0)] return _dirs[d] static func ToDir(p: Vector2i): if(p.y < 0): return Dirs.NORTH if(p.x < 0): return Dirs.WEST if(p.y > 0): return Dirs.SOUTH return Dirs.EAST
BattleState
In “BattleState.gd” we’ll add a couple variable getters that we didn’t add in earlier lessons to simplify our code a little. At the beginning of the file with the other variables add
var pos:Vector2i: get: return _owner.board.pos var board:BoardCreator: get: return _owner.board
Camera Controller
In “CameraController.gd”, it was brought to my attention in the comments by CatPatron, that my numbers in AdjustedMovement() were wrong, and after looking at them, I have no idea how sleep deprived I must have been when I came up those numbers when working on making tweaks to the original code, and did not think to question them. They were close enough to mostly work, but here are the correct numbers to use in the function. As we are dealing with directional input in some of the ranges, we might as well have something more accurate.
if ((angle >= -45 && angle < 45) || ( angle < -315 || angle >= 315)): return originalPoint elif ((angle >= 45 && angle < 135) || ( angle >= -315 && angle < -225 )): return Vector2i( originalPoint.y, originalPoint.x * -1) elif ((angle >= 135 && angle < 225) || ( angle >= -225 && angle < -135 )): return Vector2i(originalPoint.x * -1, originalPoint.y * -1) elif ((angle >= 225 && angle < 315) || ( angle >= -135 && angle < -45 )): return Vector2i(originalPoint.y * -1, originalPoint.x)
Ability Range Base Class

Let’s start by creating two new folders. Inside the folder “Scripts->View Model Component”, create a folder named, “Ability” and inside that folder, create one named “Range”
Create a new script “AbilityRange.gd” in the folder we created, “Scripts->View Model Component->Ability->Range”
extends Node class_name AbilityRange @export var horizontal:int = 1 @export var minH:int = 0 @export var vertical:int = 999999 var directionOriented:bool: get = _get_directionOriented func _get_directionOriented(): return false var unit:Unit: get: return get_node("../../../") func GetTilesInRange(board:BoardCreator): pass
We have three @export variables here that will be used in most range abilities. “horizontal” for the max range, “minH” for the minimum range, and “vertical” will allow us to set the max height difference abilities can affect, defaulting at a high number to ensure default encompasses all heights.
The next variable, “directionOriented” is a bit different. Instead of a normal getter, we are linking the get method up to a function. This is because Godot does not allow variables to be overridden by child classes. Functions however can be, so here we have a default function “_get_directionOriented()” that will by default return false, but any class that inherits this can simply override the function to provide a different default. We’ll use this variable to enable us to switch to a direction based input system for ranges that we want, such as for the line and cone patterns. We might use a normal range when selecting where a fireball might land, but a dragon might choose a cone based on the direction he is facing to blast everything in the way.
“unit” goes up the chain and returns the main unit node. The hierarchy will look like this once we create the nodes.

“GetTilesInRange()” is empty here, but each class that inherits this will need to create their own implementation to function.
Constant Ability Range

Create a script named “ConstantAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
Our first real range subclass. This one is probably going to be the most used. We call “board.RangeSearch()” here. We don’t care what steps are walkable when trying to cast a fireball or lightning bolt, so we can use “RangeSearch()”. “ExpandSearch() will get called in the search function and filter out any distances that are too far/close.
extends AbilityRange func GetTilesInRange(board: BoardCreator): var retValue = board.RangeSearch(unit.tile, ExpandSearch, horizontal ) return retValue func ExpandSearch(from:Tile, to:Tile): var dist = abs(from.pos.x - to.pos.x) + abs(from.pos.y - to.pos.y) return dist <= horizontal && dist >= minH && abs(from.height - to.height) <= vertical
Self Ability Range

Create a script named “SelfAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
Some buffs or other spells may only target the user. When that is the case, we don’t need to search the entire board, we can just grab the tile directly from the unit.
extends AbilityRange func GetTilesInRange(board:BoardCreator): var retValue:Array[Tile] = [] retValue.append(unit.tile) return retValue
Infinite Ability Range

Create a script named “InfiniteAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
This one is another super simple one. Only three lines of code… or it would have been if I didn’t get the idea to expand it to create the base of an ability like the Calculator in Final Fantasy Tactics. Here is the simple three line version…
extends AbilityRange func GetTilesInRange(board:BoardCreator): return board.tiles.values()
For those that don’t know, the Calculator in FFT is a special magic class that is able to use math formulas instead of a traditional range for their spells. For instance they can target all units, anywhere on the board, that have a level that is a prime number, or any unit standing on a tile with a height divisible by 2. So, here is the more complicated version that you can modify to add whatever craziness you want. I created a simple height divisible by 2 example to get you started.

extends AbilityRange enum Calc{ ALL, HEIGHT, } @export var option:Calc = Calc.ALL func GetTilesInRange(board:BoardCreator): if option == Calc.ALL: return board.tiles.values() else: var retValue:Array[Tile] = [] for tile in board.tiles.values(): if ValidTile(tile): retValue.append(tile) return retValue func ValidTile(t:Tile): match option: Calc.HEIGHT: if t.content != null: if t.height % 2 == 0: return true _: return false return false
We start off by creating an enum to hold our options. In the GetTilesInRange() function, we start by checking if the option is Calc.ALL, if it is, we just return all the tiles. If that’s not the case, we’ll need to loop through the list of tiles to see which match our criteria.
The function ValidTile(), we create a block for each type of calculation we want to do, in our case just Calc.HEIGHT, and a default fallback. We first check if there is an object on the tile, and if there is, we check if the tile’s height is divisible by 2.
Cone Ability Range

Create a script named “ConeAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
This one is our first direction based range. We’ll be using the direction a unit is facing to calculate where to point the range.
extends AbilityRange @export var offset:int = 1 func _get_directionOriented(): return true func ValidTile(t:Tile): return t != null && abs(t.height - unit.tile.height) <= vertical func GetTilesInRange(board:BoardCreator): var retValue:Array[Tile] = [] var pos:Vector2i = unit.tile.pos var dir:Vector2i = Directions.ToVector(unit.dir) var secDir:Vector2i = Vector2i(1,1) - abs(dir) for i in range(minH, horizontal+1): for j in range(-i + offset, i - offset + 1): var tileVector:Vector2i = pos + dir*i + secDir*j var tile:Tile = board.GetTile(tileVector) if ValidTile(tile): retValue.append(tile) return retValue
A few things going on in this code. “offset” is how far in front of(or behind) the character the tip of the cone is. We override the function “_get_directionOriented()” so we can set variable “directionOriented” in the base class to true.
In the GetTilesInRange() function, “secDir” we are flipping the vector “dir” to give us the perpendicular angle so we can calculate the thickness of the cone at any given point.
Finally it’s just a matter of looping through the points and multiplying by the direction vectors to face it in the correct direction.
Line Ability Range

Create a script named “LineAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
The line is another range based on the direction the unit is facing.
This one is pretty similar to the cone, except the inner for loop is a constant thickness. The width variable is a little deceiving, each value increases the line thickness by two. This is because we are adding to both sides of the initial center line. I didn’t add even thicknesses because we’d have to decide how to deal with the asymmetry.
The other difference is that we may want the line to extend infinitely to the edge of the board. Because of this, we add a check to stop searching once we hit the edge of the map. There is no reason to continue checking for thousands of tiles when we’ve already passed the furthest out tile.
extends AbilityRange @export var width:int = 1 func _get_directionOriented(): return true func GetTilesInRange(board:BoardCreator): var retValue:Array[Tile] = [] var pos:Vector2i = unit.tile.pos var dir:Vector2i = Directions.ToVector(unit.dir) var secDir:Vector2i = Vector2i(1,1) - abs(dir) for i in range(minH, horizontal+1): for j in range(-width+1, width): var tileVector:Vector2i = pos + dir*i + secDir*j if(tileVector.x > board.max.x || tileVector.x < board.min.x): break if(tileVector.y > board.max.y || tileVector.y < board.min.y): break var tile:Tile = board.GetTile(tileVector) if ValidTile(tile): retValue.append(tile) return retValue func ValidTile(t:Tile): return t != null && abs(t.height - unit.tile.height) <= vertical
Cross Ability Range

Create a script named “CrossAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
The first of our bonus ability ranges, this is essentially the non directional version of the Line Ability Range that we just completed. Instead of getting the direction the player is facing, we just return all four directions. Because the user is expected to be selecting a specific tile in the range, instead of a general range, there is no expectation that this would be used to create an infinite wide line, so we can skip checking for board extents.
extends AbilityRange @export var width = 1 var _dirs = [Vector2i(0,1), Vector2i(1,0), Vector2i(0,-1), Vector2i(-1,0)] func ValidTile(t:Tile): return t != null && abs(t.height - unit.tile.height) <= vertical func GetTilesInRange(board:BoardCreator): var retValue:Array[Tile] = [] var pos:Vector2i = unit.tile.pos for dir in _dirs: var secDir:Vector2i = Vector2i(1,1) - abs(dir) for i in range(minH, horizontal+1): for j in range(-width+1, width): var tileVector:Vector2i = pos + dir*i + secDir*j var tile:Tile = board.GetTile(tileVector) if ValidTile(tile): retValue.append(tile) return retValue
Diagonal Cross Ability Range

Create a script named “DiagonalCrossAbilityRange.gd” in the folder “Scripts->View Model Component->Ability->Range”
This one is the most complicated of all the shapes, and took me a bit to wrap my head around how to make it work with variable thickness.
In the for loop for “w” the first value “w == 1” is our main diagonal line. We’ll base the other lines around it on this one.
If we were to then draw a line from the next diagonal tile to get our thickness, we would end up with gaps in the center of our range.

To fix this, we iterate over the halfway points as well. To do this, we need to alternate between moving our line up on the x and y axis. To achieve this we multiply the x and y values by w%2, which will give us the zero on even loops, and (1-w%2) which will give us zero on odd loops.
The final if statements in the loop are to crop off the inner fill lines at the edge of our range so we get a nice clean rectangular line.
extends AbilityRange @export var width = 1 var _dirs = [Vector2i(1,1), Vector2i(-1,-1), Vector2i(1,-1), Vector2i(-1,1)] func ValidTile(t:Tile): return t != null && abs(t.height - unit.tile.height) <= vertical func GetTilesInRange(board:BoardCreator): var retValue:Array[Tile] = [] var pos:Vector2i = unit.tile.pos for i in range(minH, horizontal+1): for dir in _dirs: var sideA:Vector2i = Vector2i(-dir.x, dir.y) var sideB:Vector2i = Vector2i(dir.x, -dir.y) var vectorA:Vector2i var vectorB:Vector2i for w in range(1, width+1): if w == 1: var tileVector:Vector2i = pos + dir*i var tile:Tile = board.GetTile(tileVector) if ValidTile(tile): retValue.append(tile) vectorA = dir * i vectorB = dir * i else: vectorA = Vector2i(vectorA.x + sideA.x * w%2, vectorA.y + sideA.y * (1 - w%2)) vectorB = Vector2i(vectorB.x + sideB.x * (1 - w%2), vectorB.y + sideB.y * w%2) if (abs(vectorA.x) + abs(vectorA.y)) <= horizontal * 2: var tile:Tile = board.GetTile(vectorA + pos) if ValidTile(tile): retValue.append(tile) if (abs(vectorB.x) + abs(vectorB.y)) <= horizontal * 2: var tile:Tile = board.GetTile(vectorB + pos) if ValidTile(tile): retValue.append(tile) return retValue
Other Shapes
I think that’s enough shapes for now. There are other’s I considered, but I think for a tutorial, this should be more than enough to get people started. One I had considered was creating a more rounded circle from this article Circle fill on a grid on Red Blob Games. The circles created play with several different parameters to get some different results than the normal more diamond shape standard move range. Abilities like this also pose some interesting questions about tactics. Because the range does not line up exactly with the range of movement, you could get some varied chase sequences that you will have to decide if are a desireable outcome.
Turn
In “Turn.gd” we need to add a line to hold the reference to the node that will store our abilities. With the other variables, add the line:
var ability: Node
Hero Prefab
Lets take a moment to set up our hero prefab. This will have the hierarchy that we mentioned earlier.

Open the “Hero.tscn” prefab scene. Create a child of the main node named “Abilities” of type “Node”. To that we will create another child of type “Node” named “Attack”, and as one final child create a “Node” named “Ability Range”. To this, attach one of the ability range scripts. We are doing this manually for now, but later we will set up system to add them in code when we set up the individual attacks. There are a few more things to add before we can test this, but once we begin testing, you can swap out which range type is attached to the node to get different patterns. Once the script is attached, we can head to the inspector of the “Ability Range” node and set the individual value for our ranges. They all should have the basic “minH” “horizontal” and “vertical”, as these are used in most of the ranges, with exceptions to ones like infinite and self ranges.
Ability Target State
We’re adding one new State this time, in “Scripts->Controller->Battle States” create a new script named “AbilityTargetState.gd”
In the Enter() function, we get the node from “turn.ability” which would be the node “Attack” for the time being, and search for children with an ability range attached.
In OnMove(), if the range is direction based, the unit will change direction, and if it is not a directional attack, the cursor should move around the board to select a tile. There are no spells attached to our ranges yet, so selecting anything at this point will confirm the selection, it is not confined to the selected tiles.
extends BattleState @export var commandSelectionState: State @export var categorySelectionState: State var tiles = [] var ar:AbilityRange func Enter(): super() var filtered: Array[Node] = turn.ability.get_children().filter(func(node): return node is AbilityRange) ar = filtered[0] SelectTiles() statPanelController.ShowPrimary(turn.actor) if(ar.directionOriented): RefreshSecondaryStatPanel(pos) func Exit(): super() board.DeSelectTiles(tiles) await statPanelController.HidePrimary() await statPanelController.HideSecondary() func OnMove(e:Vector2i): var rotatedPoint = _owner.cameraController.AdjustedMovement(e) if(ar.directionOriented): ChangeDirection(rotatedPoint) else: SelectTile(rotatedPoint + pos) RefreshSecondaryStatPanel(pos) func OnFire(e:int): if(e == 0): turn.hasUnitActed = true if(turn.hasUnitMoved): turn.lockMove = true _owner.stateMachine.ChangeState(commandSelectionState) else: _owner.stateMachine.ChangeState(categorySelectionState) func ChangeDirection(p:Vector2i): var dir:Directions.Dirs = Directions.ToDir(p) if(turn.actor.dir != dir): board.DeSelectTiles(tiles) turn.actor.dir = dir turn.actor.Match() SelectTiles() func SelectTiles(): tiles = ar.GetTilesInRange(board) board.SelectTiles(tiles) func Zoom(scroll: int): _owner.cameraController.Zoom(scroll) func Orbit(direction: Vector2): _owner.cameraController.Orbit(direction)
Now that we’ve finished this script, save and let’s go to our main “Battle.tscn” scene. In the Scene Tree, under “Battle->Battle Controller->State Machine” create a new node of type “Node” named “Ability Target State” and attach the script we just made to it. In the inspector assign the “Command Selection State” and “Category Selection State” variables.
Category Selection State
The last script we need to modify, “CategorySelectionState.gd”. At the top with the other @export variables we need to add a variable to hold our new state.
@export var abilityTargetState:State
The Attack() function we will replace with this new code, where we get the first child node of “Abilities”, the “Attack” node, and change state to our new “Ability Target State”
func Attack(): var abilities:Array[Node] = turn.actor.get_node("Abilities").get_children() turn.ability = abilities[0] _owner.stateMachine.ChangeState(abilityTargetState)
With those changes, head back into the Battle scene and the “Category Selection State” node, and in its inspector, assign the new variable to its corresponding state.
Demo
That was the last of our additions for now. Go ahead and test the range with various values. If you want to test a new range, just change the script in the “Hero.tscn” prefab. Most of the attacks make the most sense with “minH” set to either 0 or 1. “horizontal” will be generally in the 3-7 range, although these values are entirely up to you. Offset it the cone generally works fairly well with the same starting “minH” value, but setting “offset” slightly behind the “minH” value can have some interesting results as well.
What’s Next?
The next lesson we’ll be continuing the range theme and adding Area of Effect, so that once we select a tile within range, the attack itself will have the ability to target a single unit, or explode out, hitting multiple units.
Summary
This time around we took our first step to creating abilities that we’ll actually be able to use. There were a number of range options that we created, and even a few bonus ones, all of which we’ll be able to attach to the abilities we create in the future.
As always, if you need help or have questions, feel free to leave a comment, or check out the repository to compare your code.
Good one!
I’ll be honest on the X range one, I did not do the math and it seems to be doing a nice X so I’ll trust you on that one 😉
I’ve got a question about the code changes we made at the beginning. Did you think about them before even writing a single line of code? Did you simply tried writing like the first ability range class and be like “ah shit, I’d really need this”? I did not see it directly when writing my own and had to go back in after and I wonder how did you come to these.
Have a great evening! Thanks again for this one 😉
Things like adding the check on the start tile I figured out were needed when testing, as the unit’s tile with minimum ranges weren’t being excluded like they should have. Some of the others were in the original tutorial although later in the description, like board min/max and getting a direction from a point. I think probably the main one I figured I needed early was a way to get the four vectors to loop through, ToVector(). I really wanted to use a math based approach to get the points, both to simplify and add additional functionality like line thickness, so I needed a direction to start with. So I guess the answer is, it was a mixture?
Anyway, glad you are still enjoying the series.
Just wanted to thank y’all for these tutorials! I’ve been a huge FFT fan for awhile and was interested in making a game in that vein, and something like this series is a monumental help.