Tactics RPG Turn Order

As it is now, turns are completely linear as if our units were all standing in line. In a tactics battle, things are a lot more complex. Units with a higher speed stat should be able to act more quickly in general, but other exceptions to the rule might exist. Final Fantasy Tactics for example would include status effects such as Haste, Slow, or even Stop. In this lesson we will create a new class to determine when each unit on the board gets to take a turn while keeping things flexible enough to account for all of these types of scenarios.

StatTypes

I decided to add another entry to our StatTypes enum called CTR which stands for “counter” – it is a stat that is used to determine when a unit gets to act. Except in special conditions, it will increment according to the SPD stat and when it passes a certain threshold value, the Unit will be eligible for a turn. Simply taking a turn will knock this number back down, but choosing to take an action and or move will knock it down further still.

Open the StatTypes script and add the following entry inside the enum (just before Count):

CTR, // Counter - for turn order

Turn Order Controller

Create a new script named TurnOrderController in the Scripts/Controller folder.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class TurnOrderController : MonoBehaviour 
{
	#region Constants
	const int turnActivation = 1000;
	const int turnCost = 500;
	const int moveCost = 300;
	const int actionCost = 200;
	#endregion

	#region Notifications
	public const string RoundBeganNotification = "TurnOrderController.roundBegan";
	public const string TurnCheckNotification = "TurnOrderController.turnCheck";
	public const string TurnCompletedNotification = "TurnOrderController.turnCompleted";
	public const string RoundEndedNotification = "TurnOrderController.roundEnded";
	#endregion

	#region Public
	public IEnumerator Round ()
	{
		BattleController bc = GetComponent<BattleController>();;
		while (true)
		{
			this.PostNotification(RoundBeganNotification);

			List<Unit> units = new List<Unit>( bc.units );
			for (int i = 0; i < units.Count; ++i)
			{
				Stats s = units[i].GetComponent<Stats>();
				s[StatTypes.CTR] += s[StatTypes.SPD];
			}

			units.Sort( (a,b) => GetCounter(a).CompareTo(GetCounter(b)) );

			for (int i = units.Count - 1; i >= 0; --i)
			{
				if (CanTakeTurn(units[i]))
				{
					bc.turn.Change(units[i]);
					yield return units[i];

					int cost = turnCost;
					if (bc.turn.hasUnitMoved)
						cost += moveCost;
					if (bc.turn.hasUnitActed)
						cost += actionCost;

					Stats s = units[i].GetComponent<Stats>();
					s.SetValue(StatTypes.CTR, s[StatTypes.CTR] - cost, false);

					units[i].PostNotification(TurnCompletedNotification);
				}
			}
			
			this.PostNotification(RoundEndedNotification);
		}
	}
	#endregion

	#region Private
	bool CanTakeTurn (Unit target)
	{
		BaseException exc = new BaseException( GetCounter(target) >= turnActivation );
		target.PostNotification( TurnCheckNotification, exc );
		return exc.toggle;
	}

	int GetCounter (Unit target)
	{
		return target.GetComponent<Stats>()[StatTypes.CTR];
	}
	#endregion
}

First, I added a few constants. It’s never a good idea to hard-code values in your code. These are sometimes referred to as “mystery numbers” because you don’t always understand the intent behind the number. When you name the value with a constant, the intent is much more obvious. In addition, should you ever decide to alter the values, you can do it in one place, instead of scattered throughout your script.

  • turnActivation – this is the minimum threshold value that the CTR stat must reach before a unit is eligible for a turn (except under special conditions).
  • turnCost – this is the minimum debit to the CTR stat when a unit takes its turn.
  • moveCost – this is an optional debit to the CTR stat, if the unit chooses not to move, it might get another turn more quickly.
  • actionCost – this is an optional debit to the CTR stat, if the unit chooses not to take an action (like attack), it might get another turn more quickly.

Next we have a few notifications. These are also defined as constants. Note that I used the class as a prefix within the string itself. This practice will help to avoid “collisions” in your notification names. It is entirely possible for example that I would have another “roundBegan” notification in some other class, but with the prefix of the class in the notification itself, I will be able to avoid accidental notification handling.

The real meat of this class is the Round method. Note that it is an IEnumerator but we will not be using Unity’s StartCoroutine to use it. Instead, we will use it natively and chose to Step through the code based on actually completing turns through our Battle States. We have done this before, such as with the ConversationController, but if you haven’t quite mastered the idea yet, hopefully seeing it again will help.

I am using an “infinite loop” here by using while (true). Be careful with these because if there is no “pause” point your game will “freeze”. In our case, there is always a “pause” with each unit’s turn, and if we code correctly, a game over state will be recognized before we ever run into a situation where there are no units which can take a turn.

Each cycle of the while loop is considered a complete “Round” – where a round allows all units which are allowed to take a turn to actually take their turn. At the very beginning of this round I post a notification letting our game know that a new round has begun. You could use this notification for any number of things (or nothing at all) but I will probably use it as an opportunity to decrement timers on status effects. For example “Poison” could last for “X” number of rounds – each round it would decrement a “duration” variable and if the value reaches zero could remove itself.

Next, I create a copy of the BattleController’s units list. I do this because it is generally a bad idea to iterate over a list which has the chance of being modified while you are iterating over it. Plus I want to be able to sort the list, but I don’t want the original list to be modified (the order there isn’t important now, but in the future who knows?).

Using our copied list of units, we iterate over each unit and increment the CTR stat by the SPD stat. Note that because we are using the indexer the stat component will allow the value to have exceptions applied. Any class can listen to the corresponding WillChange notification, grab the ValueChangeException argument and make changes. For example I could append a MultValueModifier – a multiplier of “2” could implement a Haste status effect or a multiplier of “0.5” could implement a Slow status effect. You can also simply FlipToggle on the exception and have the implementation for Stop.

Now that the final CTR values for all of the units are known, we sort the list of units according to that stat. Looping through the list again from highest to lowest, we allow any unit which can take a turn, to actually take a turn. When we check whether or not a unit can take a turn, we post another notification which allows our game to make some exceptions. By default, the exception is based on whether or not the unit has a high enough CTR stat to overcome the threshold. However, there are reasons we might not want to allow the unit to take a turn – such as if the unit has been defeated. Alternatively, you might come up with reasons that a unit which normally wouldn’t be able to take a turn, now can take a turn. This notification is your opportunity to make those sorts of changes.

When we find a unit which can act, we assign it to the BattleController’s Turn object. Then we “pause” execution of this loop using the yield statement which will allow control to be passed back to our BattleStates (and other code). Whenever we “continue” execution (through another BattleState) it will pick up exactly where it left off. We can check what has been done on that turn and reduce the CTR stat accordingly. I also fire a notification once the final costs have been applied and the turn is truly at its most “complete” point, though at the moment I don’t know what action I will take here – if any.

After giving all of the units a chanace to take a turn, we post a round complete notification and the whole process will then repeat itself.

Battle Controller

Because the BattleController maintains references to pretty much anything I could want, I will add a reference there for our TurnOrderController‘s round enumerator.

public IEnumerator round;

Init Battle State

To initialize the round enumerator we will go ahead and use our InitBattleState. Add the following line to the Init method:

owner.round = owner.gameObject.AddComponent<TurnOrderController>().Round();

Select Unit State

We will use the SelectUnitState to handle advancement of the round enumerator. Remove the old logic for stepping through each unit and use the following instead:

using UnityEngine;
using System.Collections;

public class SelectUnitState : BattleState 
{
	public override void Enter ()
	{
		base.Enter ();
		StartCoroutine("ChangeCurrentUnit");
	}

	public override void Exit ()
	{
		base.Exit ();
		statPanelController.HidePrimary();
	}

	IEnumerator ChangeCurrentUnit ()
	{
		owner.round.MoveNext();
		SelectTile(turn.actor.tile.pos);
		RefreshPrimaryStatPanel(pos);
		yield return null;
		owner.ChangeState<CommandSelectionState>();
	}
}

Demo

Open our battle scene – press “Play” and experiement to see who moves first – who had the highest speed stat? Make one unit move and act, another only move or act, and one simply wait. Who moves first on the next round? If everything worked correctly you should see the turn order changing based on your actions.

Summary

This week we replaced our placeholder linear turn order system with a notably more complex and dynamic system. The order a unit moves can be determined by a large number of factors including their stats and any number of other scripts such as those which give status effects. In addition, turns can be bypassed completely such as by a unit being KO’d.

Don’t forget that the project repository is available online here. If you ever have any trouble getting something to compile, or need an asset, feel free to use this resource.

7 thoughts on “Tactics RPG Turn Order

  1. I have made a terrible mistake somewhere. I can’t seem to figure out how to get rid of this error.

    “NullReferenceException: Object reference not set to an instance of an object
    InitBattleState.SpawnTestUnits () (at Assets/Scripts/Controller/Battle States/InitBattleState.cs:44)
    InitBattleState+c__Iterator1.MoveNext () (at Assets/Scripts/Controller/Battle States/InitBattleState.cs:17)
    UnityEngine.MonoBehaviour:StartCoroutine(IEnumerator)
    InitBattleState:Enter() (at Assets/Scripts/Controller/Battle States/InitBattleState.cs:9)
    StateMachine:Transition(State) (at Assets/Scripts/Common/State Machine/StateMachine.cs:40)
    StateMachine:set_CurrentState(State) (at Assets/Scripts/Common/State Machine/StateMachine.cs:9)
    StateMachine:ChangeState() (at Assets/Scripts/Common/State Machine/StateMachine.cs:24)
    BattleController:Start() (at Assets/Scripts/Controller/BattleController.cs:22)”

    1. Just in case you are unfamiliar, a NullReferenceException indicates that you are trying to “work” with an object that isn’t there. For example, you can’t call a method on a script that you haven’t assigned, etc. Line 44 only has a few possible things which could be null:

      1.) Either the Hero Prefab didn’t have a “Unit” component so line 43 returned null (fix by adding the component to the prefab in the project pane)
      2.) The board reference is null (fix by making sure the BattleController has a reference to the board in the scene)

      I usually try troubleshooting this problem by adding some Debug.Logs just before the line that causes the error, such as
      Debug.Log(“Do I have a board? ” + (board != null));

  2. Ya, it looks like the Unit component got deleted off it or not saved lol. Thanks! Also, random question with your usage of Time.deltaTime. I don’t know much about networking but it seems like you might run into problems if you are playing a multiplayer game and each device has its own time. Do you know the way around this off hand or is it not actually the problem that I appears to be?

    1. Great questions. Unfortunately I haven’t done any multiplayer programming where timing and position is important (such as a first person shooter). If you were playing a turn based rpg like this it shouldn’t matter the speed at which turns complete, but the Time.deltaTime would still be useful to make it visually consistent across devices and platforms. I would think that even with the internet multiplayer stuff you would do general placement via Time.deltaTime and wait for server feedback to make small corrections as necessary, but you are better off asking someone who has actually done it 🙂

  3. With the data that the TurnOrderController cultivates (basically the sorted final CTR list) is it possible to display that data onscreen aside from a debug. Ultimately I would like to a have a list of the current order that the player can see. I’ve a feeling it’s similar to the AbilityMenuPanelController script without input control so not as complex but I’m scratching my head on how to “marry” the two because I don’t need the CTR value to display, just the unit’s name.

    1. I dont think the structure here is ideal for what you want, because the units are only sorted at the beginning of a round, but you would probably want them to remain sorted after every unit’s turn. However, the feature you want to add is not difficult and could easily exist in its own controller script. You could try something like listen for the “TurnCompletedNotification”, and then sort the entire list of units by the CTR stat (just like we do in the TurnOrderController script. You can get the name of the unit because it is the name of the GameObject that the Unit component is attached to. In other words you could use something like:

      “units[i].name”

Leave a Reply

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