Godot Tactics RPG – 14. Ability Area of Effect

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.

Godot Tactics RPG – 13. Ability Range

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.

Continue reading

Godot Tactics RPG – 12. Stat Panel

7thSage again. This time we’ll be creating some more UI elements. We’ve been working with stats for a few lessons now, and its time we created something to show at least some of them. We’ll create a pair of panels to show the character’s name, level, HP and MP values. We’ll be mostly using the primary panel for now. The secondary panel we’ll use later for the target of an action.

Continue reading

Godot Tactics RPG – 11. Jobs

7thSage again. This time we’ll be working on adding a job system. Providing different stats and growth characteristics depending on which job a unit has. We’ll be loading in a .csv file, and creating a prefab for each job in code. We’ll also be adding some of the elements from the last couple lessons to our actual game characters.

Continue reading

Godot Tactics RPG – 05. Pathfinding

It’s me 7thSage again. I’m back again for part 5. Pathfinding. I expect that this is probably the one a lot of you have been waiting for. Some of you may be expecting us to go down the road of A*(read as A Star), instead we’ll be using a simpler, and in this case faster, method. As we need to find all possible tiles a unit can move to, A* quickly bogs down because it is designed to find a single path from point A to B as quickly as it can, but finding a path from A to B-Z is a lot of individual paths. During our search instead of saving multiple paths, we leave trails of breadcrumbs that we can follow back from any tile we later choose.

Continue reading