Godot Tactics RPG – 15. Turn Order

7thSage again, welcome back to part 15 – Turn Order. Up until this point, units have taken turns one after another, with no consideration for speed or any variation. In a tactics game such as Final Fantasy Tactics, units get their turn based on their individual speed, which can also be affected by things such as slow and haste. In this lesson we’ll be creating a controller class that will be able to handle cases such as those.

Stat Types

When we initially created our list of stats, we didn’t have turn order in mind, so we need to create one more. CTR, which stands for “counter”, will be used to keep track of how much time has passed for each character. Whenever it passes a certain value, the character takes their turn, and the counter will be wound back a certain value, based on the actions that have been made.

In “StatTypes.gd”, add the stat to the end of our list, just before “Count”

CTR, # Counter - for turn order

Turn Order Controller

Create a new script named “TurnOrderController.gd” in the folder “Scripts->Controller”

extends Node
class_name TurnOrderController

const turnActivation:int = 1000
const turnCost:int = 500
const moveCost:int = 300
const actionCost:int = 200

signal roundBeganNotification()
signal turnCheckNotification(exc:BaseException)
signal turnCompletedNotification(unit:Unit)
signal roundEndedNotification()
signal roundResume()

func _ready():
	Round()

func Round():
	var bc:BattleController = get_parent()
	await roundResume
	while true:
		roundBeganNotification.emit()
		
		var units:Array[Unit] = bc.units.duplicate()
		for unit in units:
			var s:Stats = unit.get_node("Stats")
			s.SetStat(StatTypes.Stat.CTR, s.GetStat(StatTypes.Stat.CTR) + s.GetStat(StatTypes.Stat.SPD)) 
			
		units.sort_custom(func(a,b): return GetCounter(a) > GetCounter(b))
		
		for unit in units:
			if(CanTakeTurn(unit)):
				bc.turn.Change(unit)
				await roundResume
				var cost:int = turnCost
				if(bc.turn.hasUnitMoved):
					cost+= moveCost
				if(bc.turn.hasUnitActed):
					cost+= actionCost
				
				var s:Stats = unit.get_node("Stats")
				s.SetStat(StatTypes.Stat.CTR, s.GetStat(StatTypes.Stat.CTR) - cost, false) 
				turnCompletedNotification.emit(unit)
		roundEndedNotification.emit()
				
func CanTakeTurn(target:Unit):
	var exc:BaseException = BaseException.new( GetCounter(target) >= turnActivation)
	turnCheckNotification.emit(exc)
	return exc.toggle
	
func GetCounter(target:Unit):
	var s:Stats = target.get_node("Stats")
	return s.GetStat(StatTypes.Stat.CTR)

The script starts off with a few constants so we can avoid hard coded mystery numbers, and keep all the values for our turn in one place.

  • turnActivation – This is the minimum value the CTR stat needs to hit before a character is able to take a turn.
  • turnCost – This is the minimum value that CTR is set back once a turn is finished.
  • moveCost – Additional amount that CTR is set back if a unit moves. If they choose not to move, their next turn may come more quickly.
  • actionCost – Additional amount that CTR is set back if a unit takes an action. If they choose not to take an action, their next turn may come more quickly.

Next is several signals we can use to send notifications about some different things happening during our turn. There is also a signal for roundResume, that we will be using to trigger our function to continue on to the next character or tick of the turn controller.

The function Round() is the meat of our Turn Controller. We’ll be using it to loop through our characters and modify their CTR stats once done. This class works similar to the coroutine that we used in the Conversation Controller.

The coroutine employs an infinite “while true” loop. As long as there are units on the field, there will be characters to loop through. At some point in the future, if there is a state where units are defeated or another victory/loss condition is reached, we can implement a way to end the loop.

In this implementation a “round” is a single loop through all the characters to determine whether they are capable of taking a turn. As such, there will generally be several “rounds” before each character is able to take their turn. If you are looking for a feature that is more aligned with character “turns”, such as if you want to have a “condition for completing the battle in X turns”, then I think I would implement one of a couple options.

The first option could simply count the turns of the main character. This means a turn would be affected by the character’s speed and any ability that changes it. So that may or may not be what you want.

The second option is to define a “turn” as either a specific number of rounds, say 10 rounds, or give each round its own semi speed value, say 100. As opposed to just counting rounds, using the speed value lets you adjust how often a “turn” comes by. For instance, using a speed of 100, it would give the effect of a turn being equivalent to a character with 100 speed, using all their actions during their turn. A new turn comes around whenever the value hits the threshold just like a character’s CTR reaching the turn threshold. If you want to have a baseline that compensates for users on average using only some of their available actions, you could set that speed a little over 100 as one idea.

We start the while loop by making a copy of the unit list. This way we can modify it without worrying about it impacting other functions that may need it somewhere else. Next we grab the stat node and each round we add the unit’s speed, SPD, to their CTR stat. Once it hits 1000 they will be able to take their turn. Once all the CTR stats have been updated, we sort our new unit list based on their CTR stat.

Our for loop then loops through the characters and once a unit is selected it pauses until the unit is done with their actions and fires the signal to continue, where it will subtract the CTR values based on which actions have been taken during the turn. Following that, it will continue on to the next character, and once all characters are finished, will start the loop over for the next round.

Battle Controller

In “BattleController.gd”, with the other @export variables, add the line

@export var turnOrderController:TurnOrderController

Next, in the “Battle” scene, in the Scene View, under the node “Battle Controller” create a new child node named “Turn Order Controller” and attach the script “TurnOrderController.gd” to it. Once the script is attached, select the “Battle Controller” node and in the Inpsector, assign the Turn Order Controller node to its variable.

Battle State

In “BattleState.gd” let’s add another helper variable so we don’t have to type out a long chain going through the “_owner” variable. With the other variables, add the lines

var turnController:TurnOrderController:
	get:
		return _owner.turnOrderController

Select Unit State

In “SelectUnitState.gd”, we can remove the index variable, as we won’t need it here anymore. We also need to change up a couple lines in the ChangeCurrentUnit() function. The first line we send a signal to tell our turn controller to continue, and then we select the unit’s tile. The ChangeState will look the same as it did before. The end result will look something like this.

extends BattleState

@export var commandSelectionState: State

func Enter():
	super()
	ChangeCurrentUnit()
	
func ChangeCurrentUnit():
	turnController.roundResume.emit()
	SelectTile(turn.actor.tile.pos)	
	_owner.stateMachine.ChangeState(commandSelectionState)

Demo

We can now hit play and see how the battle plays out now. Who goes first? Who is the fastest? If the fastest player just waits, how does the order change the next round? If everything is working correctly, units should be getting their turns just like normal, and the order should be changing based on the actions you took.

Summary

Our turn system got a bit of an update this time around. No longer taking simple turns one after another. Now stats, and our actions impact who will go next. With the new system, we’ll also be able to affect the turn order with things like equipment that boost speed, or status effects such as haste or slow.

If you’d like to explore some more ideas on turn order, I found this article on the Rad Codex website interesting, where he makes a case for slower units having an advantage. I think there are some additional nuances he doesn’t take into consideration, but it is nonetheless an interesting idea.

As always, if you have any questions, feel free to ask below, or check out the repository if you’d like to compare your code.

4 thoughts on “Godot Tactics RPG – 15. Turn Order

  1. Hey,
    Nice seeing your progress! I have done things a bit differently but I like your approach 🙂
    Quick question, is there any reason for creating a helper for for turnController and not for stateMachine?
    Thanks!

      1. Sorry about taking so long. I was busy with some other stuff. I’ve been working on this the last couple of days though and should have the next one finished up soon. Just getting the Status class ironed out now, I think I’ve finally got it all figured out.

        I’m gonna try putting in a lot more time into this over the next few months. I really want to try getting closer to done by the time the Final Fantasy Tactics remaster comes out.

Leave a Reply

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