Tactics RPG Jobs

In this lesson we will show how to define a variety of jobs and store their data as a project asset. Although we have done this before (with conversation assets), this time we will be creating prefabs programmatically. By choosing prefabs over scriptable objects, we have the ability to take advantage of components such as the features which we introduced in the previous lesson.

Reference

Although I have looked at several games, I’ve been spending the most time looking at Final Fantasy Tactics Advance (FFTA). You can find plenty of FAQ’s and guides online which provide a great way to understand the overall scope of the game. For example, there are around 100 different jobs specified by FFTA – each providing a variation on gameplay:

  1. Stats (some fixed like movement range, others as growth on level up)
  2. Items (what categories can be equipped)
  3. Abilities (what can be actively used while operating as that job, what can be learned and used even outside the job)
  4. Job Tree (learn enough of one job, and there may be a secondary job which opens up to you)

There is a lot of room for complexity here, but a lot of it is really dependent on your own design. Initially, our job system will be limited to determining the starting stats and growth rates of characters, but it shouldn’t be hard to add Job features to control the categories of equippable items and usable skills in much the same way as we added features to items.

Stats

I still like the idea of being able to change jobs and so I see a great reason to define a lot of different job types. We will begin by creating spreadsheets (.csv) which contain data from which to programmatically create our project assets. Of course it’s up to you to determine how you want to organize your data. Do whatever feels the best to you in order for the data to be easy to view and balance.

Here I have created a simple example with three very generic job-types. I used values somewhere within the ranges you might see from FFTA but made it my own custom list. Ideally you will do the same and flesh out many, many more jobs, rather than cheating by directly copying data from Final Fantasy, tempting though it may be. I am starting with two different spreadsheets. The first I call JobStartingStats.csv which I have placed in the Settings folder.

Name,MHP,MMP,ATK,DEF,MAT,MDF,SPD,MOV,JMP
Warrior,43,5,61,89,11,58,100,4,1
Wizard,30,25,11,58,61,89,98,3,2
Rogue,32,13,51,67,51,67,110,5,3

Note that in this case, MOV and JMP are not merely starting stats. They will actually be implemented as StatModifierFeature components so that changing to or from a job will allow the stats to fluctuate up and down.

Next I created a spreadsheet called JobGrowthStats.csv which I also placed in the Settings folder.

Name,MHP,MMP,ATK,DEF,MAT,MDF,SPD
Warrior,8.4,0.8,8.8,9.2,1.1,7.6,1.1
Wizard,6.6,2.2,1.1,7.6,8.8,9.2,0.8
Rogue,7.6,1.1,5.6,8.8,5.6,8.8,1.8

Here I have used a floating point number for each modified stat. However, it is a special convention I saw while referencing the FAQ’s. The whole number portion of the number is a fixed amount of growth in that stat with every level-up. The fractional portion of the number is a percent chance that an additional bonus point will be awarded.

For example, using the two spreadsheets above you can deduce that a character which begins the game as a Warrior will start with 43 hit points. Upon gaining a level this character’s maximum hit points will grow by a minimum of 8 but there is a 40% chance it could grow by 9.

Job

Now let’s implement the component which holds the data from our spreadsheets, and which listens to level-ups to actually apply the stat growth, etc. Create a new script called Job in the Scripts/View Model Component/Actor folder.

using UnityEngine;
using System.Collections;

public class Job : MonoBehaviour
{
	#region Fields / Properties
	public static readonly StatTypes[] statOrder = new StatTypes[]
	{
		StatTypes.MHP,
		StatTypes.MMP,
		StatTypes.ATK,
		StatTypes.DEF,
		StatTypes.MAT,
		StatTypes.MDF,
		StatTypes.SPD
	};

	public int[] baseStats = new int[ statOrder.Length ];
	public float[] growStats = new float[ statOrder.Length ];
	Stats stats;
	#endregion

	#region MonoBehaviour
	void OnDestroy ()
	{
		this.RemoveObserver(OnLvlChangeNotification, Stats.DidChangeNotification(StatTypes.LVL));
	}
	#endregion

	#region Public
	public void Employ ()
	{
		stats = gameObject.GetComponentInParent<Stats>();
		this.AddObserver(OnLvlChangeNotification, Stats.DidChangeNotification(StatTypes.LVL), stats);

		Feature[] features = GetComponentsInChildren<Feature>();
		for (int i = 0; i < features.Length; ++i)
			features[i].Activate(gameObject);
	}

	public void UnEmploy ()
	{
		Feature[] features = GetComponentsInChildren<Feature>();
		for (int i = 0; i < features.Length; ++i)
			features[i].Deactivate();

		this.RemoveObserver(OnLvlChangeNotification, Stats.DidChangeNotification(StatTypes.LVL), stats);
		stats = null;
	}

	public void LoadDefaultStats ()
	{
		for (int i = 0; i < statOrder.Length; ++i)
		{
			StatTypes type = statOrder[i];
			stats.SetValue(type, baseStats[i], false);
		}

		stats.SetValue(StatTypes.HP, stats[StatTypes.MHP], false);
		stats.SetValue(StatTypes.MP, stats[StatTypes.MMP], false);
	}
	#endregion

	#region Event Handlers
	protected virtual void OnLvlChangeNotification (object sender, object args)
	{
		int oldValue = (int)args;
		int newValue = stats[StatTypes.LVL];

		for (int i = oldValue; i < newValue; ++i)
			LevelUp();
	}
	#endregion

	#region Private
	void LevelUp ()
	{
		for (int i = 0; i < statOrder.Length; ++i)
		{
			StatTypes type = statOrder[i];
			int whole = Mathf.FloorToInt(growStats[i]);
			float fraction = growStats[i] - whole;

			int value = stats[type];
			value += whole;
			if (UnityEngine.Random.value > (1f - fraction))
				value++;

			stats.SetValue(type, value, false);
		}

		stats.SetValue(StatTypes.HP, stats[StatTypes.MHP], false);
		stats.SetValue(StatTypes.MP, stats[StatTypes.MMP], false);
	}
	#endregion
}

First, I declared an array of StatTypes called statOrder – this will serve as a convenience array to help me parse data from the spreadsheets we created earlier. It is static because it wont change from job to job and this way they can all share.

Next I defined two instance arrays, one for holding the starting stat values, and one for holding the grow stat values. I was able to init them with a length equal to the length of the statOrder array from earlier. I might have decided to implement these as a Dictionary, but because Unity doesn’t serialize Dictionaries I decided to keep it as an Array.

There are three public methods. First is Employ which should be called after instantiating a job and attaching it to an actor’s hierarchy. In this method, we get a reference to the actor’s Stats component so that we can listen to targeted level up notifications as well as apply growth to the other stats in response. In addition, this method will allow any job-based feature to become active.

If you want to switch jobs, you should first UnEmploy any currently active Job. This gives the script a chance to deactivate its features and unregister from level up notifications etc.

When creating a unit for the first time, call LoadDefaultstats so that its stats will be initiated to playable values.

Job Parser

Now it’s time to create a script which can parse our spreadsheets and create project assets from them. Create a new script named JobParser in the Editor folder.

using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Collections;

public static class JobParser 
{
	[MenuItem("Pre Production/Parse Jobs")]
	public static void Parse()
	{
		CreateDirectories ();
		ParseStartingStats ();
		ParseGrowthStats ();
		AssetDatabase.SaveAssets();
		AssetDatabase.Refresh();
	}

	static void CreateDirectories ()
	{
		if (!AssetDatabase.IsValidFolder("Assets/Resources/Jobs"))
			AssetDatabase.CreateFolder("Assets/Resources", "Jobs");
	}

	static void ParseStartingStats ()
	{
		string readPath = string.Format("{0}/Settings/JobStartingStats.csv", Application.dataPath);
		string[] readText = File.ReadAllLines(readPath);
		for (int i = 1; i < readText.Length; ++i)
			PartsStartingStats(readText[i]);
	}

	static void PartsStartingStats (string line)
	{
		string[] elements = line.Split(',');
		GameObject obj = GetOrCreate(elements[0]);
		Job job = obj.GetComponent<Job>();
		for (int i = 1; i < Job.statOrder.Length + 1; ++i)
			job.baseStats[i-1] = Convert.ToInt32(elements[i]);

		StatModifierFeature move = GetFeature (obj, StatTypes.MOV);
		move.amount = Convert.ToInt32(elements[8]);

		StatModifierFeature jump = GetFeature (obj, StatTypes.JMP);
		jump.amount = Convert.ToInt32(elements[9]);
	}

	static void ParseGrowthStats ()
	{
		string readPath = string.Format("{0}/Settings/JobGrowthStats.csv", Application.dataPath);
		string[] readText = File.ReadAllLines(readPath);
		for (int i = 1; i < readText.Length; ++i)
			ParseGrowthStats(readText[i]);
	}

	static void ParseGrowthStats (string line)
	{
		string[] elements = line.Split(',');
		GameObject obj = GetOrCreate(elements[0]);
		Job job = obj.GetComponent<Job>();
		for (int i = 1; i < elements.Length; ++i)
			job.growStats[i-1] = Convert.ToSingle(elements[i]);
	}

	static StatModifierFeature GetFeature (GameObject obj, StatTypes type)
	{
		StatModifierFeature[] smf = obj.GetComponents<StatModifierFeature>();
		for (int i = 0; i < smf.Length; ++i)
		{
			if (smf[i].type == type)
				return smf[i];
		}

		StatModifierFeature feature = obj.AddComponent<StatModifierFeature>();
		feature.type = type;
		return feature;
	}

	static GameObject GetOrCreate (string jobName)
	{
		string fullPath = string.Format("Assets/Resources/Jobs/{0}.prefab", jobName);
		GameObject obj = AssetDatabase.LoadAssetAtPath<GameObject>(fullPath);
		if (obj == null)
			obj = Create(fullPath);
		return obj;
	}

	static GameObject Create (string fullPath)
	{
		GameObject instance = new GameObject ("temp");
		instance.AddComponent<Job>();
		GameObject prefab = PrefabUtility.CreatePrefab( fullPath, instance );
		GameObject.DestroyImmediate(instance);
		return prefab;
	}
}

Because this is a pre-production script, I didn’t put a lot of effort into it. There are hard coded strings, repeated bits of code, etc that could all be cleaned up, but this is not at all re-usable, and doesn’t need to be performant, so I felt no need to waste time on it. As long as it works, I am happy.

In order to make this script work its magic, we added a MenuItem tag. As the name implies, this adds a new entry into Unity’s menu bar. You should see a new entry called “Pre Production” and under that an option called “Parse Jobs”. Select that and our Job assets will be created in the project.

You can easily delete and recreate these assets at any time. Because of this, you might choose to ignore these assets in your source control repository, not that it hurts to keep them. All you truly need to version is the spreadsheet and parser, not the result of using them together.

It is possible to “listen” for changes to your spreadsheets and have the assets re-created automatically. See my post on Bestiary Management and Scriptable Objects for an example of this.

Init Battle State

Now that movement range and jump height stats are able to be driven by a job, let’s change our SpawnTestUnits code to create one of each of the three sample job types. The code to create and configure our units is getting a bit long, and is an indication that we will probably need some sort of factory class soon.

void SpawnTestUnits ()
{
	string[] jobs = new string[]{"Rogue", "Warrior", "Wizard"};
	for (int i = 0; i < jobs.Length; ++i)
	{
		GameObject instance = Instantiate(owner.heroPrefab) as GameObject;

		Stats s = instance.AddComponent<Stats>();
		s[StatTypes.LVL] = 1;

		GameObject jobPrefab = Resources.Load<GameObject>( "Jobs/" + jobs[i] );
		GameObject jobInstance = Instantiate(jobPrefab) as GameObject;
		jobInstance.transform.SetParent(instance.transform);

		Job job = jobInstance.GetComponent<Job>();
		job.Employ();
		job.LoadDefaultStats();

		Point p = new Point((int)levelData.tiles[i].x, (int)levelData.tiles[i].z);

		Unit unit = instance.GetComponent<Unit>();
		unit.Place(board.GetTile(p));
		unit.Match();

		instance.AddComponent<WalkMovement>();

		units.Add(unit);

//		Rank rank = instance.AddComponent<Rank>();
//		rank.Init (10);
	}
}

Movement

We will also need to convert our Movement component into a wrapper much like the Rank component was. For this, add a field to store a reference to the Stats component, and then turn range and jumpHeight into properties as follows:

public int range { get { return stats[StatTypes.MOV]; }}
public int jumpHeight { get { return stats[StatTypes.JMP]; }}
protected Stats stats;

Have the component get its reference to the Stats component in the Start method:

protected virtual void Start ()
{
	stats = GetComponent<Stats>();
}

Demo

Open the main Battle scene and play it. There should be three units as there were before, but we removed the variation on movement types – everyone walks for now. See that the range of the units is different depending on the job they began with.

Switch the inspector to Debug mode so that you can see the private stat data in the Stats component. You should see the values have been set according to the starting stats which we had specified for the job.

Stop the scene and go back to the SpawnTestUnits method of the InitBattleState then uncomment the two lines where we add the Rank component and init the starting level to 10. Play the scene a few times and look at the stats of each hero. You should see slight differences in the stats thanks to the random bonus portion of the Job.

Summary

In this lesson we discussed the various purposes of a Jobs system and looked at references from the Final Fantasy series. Then we began implementing our game via spreadsheets so that it would be easy to see and balance the data. We created an editor script which could then parse our spreadsheets and create prefabs as project assets. Finally, we tied these systems back into the main game so that our demo units have stats (including movement stats) which are driven by their job.

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.

17 thoughts on “Tactics RPG Jobs

  1. A quick question for you: Why, in the updated Movement class, did you put the line where you connect the stats component in the Start method, instead of the awake method, where you have the other connectors (unit and jumper)?

    I tried it with the connector in the Start method, and the stat line was never run, causing an error when range or jumpHeight tried to reference stats- the entire Start method wasn’t run, which is strange to me. Thinking I had mistyped something, I copied the movement class from your repository, and experienced the same error. Putting the connector line in the Awake function fixed the problem.

    1. I figured it out- I had left a blank Start method in WalkMovement that was overriding the Movement.Start method.

      I am still curious about the reasoning for placing it in start vs awake, however πŸ™‚ Is it to make sure the Stats are loaded before connecting it?

      1. Good question Jordan, I don’t remember any particular reason why I put it in Start instead of Awake. Perhaps just an oversight. Sometimes there are reasons – like if I was manually adding components instead of including them with the prefab, but in this case both Awake and Start would work fine.

  2. Alright, I got prefab generation based on .asset files to work. Basically each prefab has an ‘item’ component attached, but one of the variables in item is ‘sprite’.

    I don’t think there is a way to assign a sprite in the CSV file that generates the .asset, unless I reference the image name/path directly. This seems like bad practice.

    Is there a good method for data-driving images onto a prefab?

    1. I don’t think I would reference a whole path in the csv, but referencing the name of a sprite doesn’t seem bad to me. You can also try naming conventions so that the names of prefabs have similarly named assets which can be loaded, though in many cases the simple rules end up with too many exceptions and you might as well do it all.

      1. Do you think it’s best for me to fill the item data at runtime or to do it in editor similar to the .asset file generation?

        So for example, I have a bunch of prefabs with an ‘Item’ class attached but that class doesn’t have values assigned for its variables yet. Should I assign the variables at runtime only, or would you do it automagically through Editor scripts?

        1. It depends. If you reuse the same prefab with different item data sets then I would do it at runtime. If you always pair exactly the same prefab with exactly the same data, then I would do it at Editor time.

      2. I’ve been looking for an example resource but I’m having trouble finding one. Do you know of any good resources where the name of a file is referenced in an external data file and then that file is linked to the prefab?

  3. Okay i love your tutorial so far, but now i get an error saying “ArgumentException: The thing you want to instantiate is null.” and it brings me to the line GameObject jobInstance = Instantiate(jobPrefab) as GameObject; from SpawnUnits, but i don’t understand what could be wrong here :

    1. Glad you are enjoying it. Did you remember to run the “Pre Production/Parse Jobs” menu command? You won’t be able to instantiate a job prefab if you didn’t create it first.

      1. I wish I could edit my posts here, but the problem was I had misspelled “Rogue” in the stat.csv as “Rouge”. When that was fixed it could find everything fine.

  4. Hey Men

    ive got a confusing error.

    Assets/Scripts/Exceptions/ValueChangeException.cs(39,25): error CS1501: No overload for method `Modify’ takes `1′ arguments

    and also all my skripts wont loading. (Skript can not be loaded) everywhere

    do you know what i could do to solve this mistake ?

    could it be: when i have everywehre ( using system; and using System.Collections.Generic;

    that this send me the errormessage

Leave a Reply

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