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.
There is a lot of things we can add to a job system, but for now we’ll focus on the starting stats of each, and their growth rates.
Stats
In the lesson about dialog, we imported a spreadsheet with all the language options as a “.csv” file. We’ll be creating some more “.csv” files in this lesson, but they won’t be used for translation this time. However that will also cause one problem that we’ll have to deal with when we get to it. That is that Godot thinks of all “.csv” files as translation documents by default, but don’t worry, we can override that.
For this lesson we’ll create three classes, “Warrior”, “Wizard” and “Rogue”, however feel free to create as many classes and variations to the stat values as you would like. Just be sure to keep the order and number of stats in mind.
We’ll start with the file for the starting stats. In the folder “Settings” create a new text file named “JobStartingStats.csv”. You will need to choose “All Files(*)” in the file type drop down in the bottom corner so it will let you save with just “.csv” as the ending.
After creating, it may take a few seconds before it shows up in the FileSystem view, or you may have to click out of the folder and back into it. At this point it will probably have a red X on the icon. This is because the file is currently empty. Once we get some things written inside it, the file will begin to recognize as a translation file and the icon will change. After we finish creating both files and adding their data, we’ll come back and fix it so Godot is not seeing them as translations, which ironically, will return them to having a red X as their icon.
Right click on the file and choose “Show in File Manager” to bring up the folder in your operating system. Open the file with your text editor of choice, such as Notepad if you are using Windows. We could also open the file in a spreadsheet editor, but we’d have to be careful about blank lines again. When done inputting the values, make sure there are no blank lines and save the file. Once done we can return to Godot.
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
Now we’ll go back to Godot. Once the editor detects the changes to the file, it will create the file and begin treating it like a translation resource adding the extra files for each language. Ignore these files for now, we’ll fix the issue once we have the other file done.
In the same folder create another file named “JobGrowthStats.csv”, be sure to select “All Files(*)” again. Like last time open the file in your preferred editor.
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 the values are float instead of an int. The idea here is that the whole number is the amount a stat will increase on level up, while the decimal is the percent chance that stat will receive a bonus point. Move and Jump don’t have growth stats because they will stay the same as long as that job is active.
As an example of how these work together, The Warrior starts with an attack value of 61. When he levels up, he will gain 8 points in attack, with an 80% chance that he will gain 9 instead of 8.
Clean Up
Now, to take care of those extra files. We need to start by setting the files so they aren’t treated as a translation. Next to the “Scene” tab, click the tab “Import”. Once on the tab, in the FileSystem window, select the file we created earlier, “JobStartingStats.csv”. Back up in the Import window, on the dropdown where it says “CSV Translation”, change it to “Keep File (No Import)”. Click the button “Reimport”. A dialog will pop up saying a restart of the editor is required. Select “Save Scenes, Re-Import and Restart”. When the editor comes back, the file we created earlier will now have a red x as the icon.
Do the same thing with the file “JobGrowthStats.csv”
Once you have reimported both files you can delete all the “.translation” files in the folder safely without worrying about them being autogenerated again. Be sure not to delete the files we created.
Job
Next up is a script we’ll add to a character that will hold all the stats we load in from the .csv files.
Create a new script named “Job.gd” in the folder “Scripts->View Model Component->Actor”.
extends Node class_name Job const statOrder : Array[StatTypes.Stat] = [ StatTypes.Stat.MHP, StatTypes.Stat.MMP, StatTypes.Stat.ATK, StatTypes.Stat.DEF, StatTypes.Stat.MAT, StatTypes.Stat.MDF, StatTypes.Stat.SPD ] @export var baseStats:Array[int] @export var growStats:Array[float] var stats:Stats func _init(): baseStats.resize(statOrder.size()) baseStats.fill(0) growStats.resize(statOrder.size()) growStats.fill(0) func Employ(): for child in self.get_parent().get_children(): if child is Stats: stats = child stats.DidChangeNotification(StatTypes.Stat.LVL).connect(OnLvlChangeNotification) var features:Array[Node] = self.get_children() var filteredArray = features.filter(func(node):return node is Feature) for node in filteredArray: node.Activate(self.get_parent()) func UnEmploy(): var features:Array[Node] = self.get_parent().get_children() var filteredArray = features.filter(func(node):return node is Feature) for node in filteredArray: node.Deactivate() stats.DidChangeNotification(StatTypes.Stat.LVL).disconnect(OnLvlChangeNotification) stats = null func LoadDefaultStats(): for i in statOrder.size(): stats.SetStat(statOrder[i], baseStats[i], false) stats.SetStat(StatTypes.Stat.HP, stats.GetStat(StatTypes.Stat.MHP), false) stats.SetStat(StatTypes.Stat.MP, stats.GetStat(StatTypes.Stat.MMP), false) func OnLvlChangeNotification(sender:Stats, oldValue:int): var newLevel = stats.GetStat(StatTypes.Stat.LVL) for i in range(oldValue, newLevel, 1): LevelUp() func LevelUp(): for i in statOrder.size(): var type:StatTypes.Stat = statOrder[i] var whole:int = floori(growStats[i]) var fraction:float = growStats[i] - whole var value:int = stats.GetStat(type) value += whole if randf() > (1.0 - fraction): value += 1 stats.SetStat(type, value, false) stats.SetStat(StatTypes.Stat.HP, stats.GetStat(StatTypes.Stat.MHP), false) stats.SetStat(StatTypes.Stat.MP, stats.GetStat(StatTypes.Stat.MMP), false)
The first variable, statOrder, we’ll use to reference what type each stat in our array represents. This is the same for all jobs, so we don’t need to worry about it being unique or changing it for other classes.
The next two arrays store the data we load in from the .csv files. It’s important that we use @export on both of these variables to tell Godot to save these inside the .tscn prefabs that we will be creating for the individual jobs shortly.
And the last variable for now, we have a reference to the Stats object that will hold all the current stats on the character.
The _init() function we set the size and initialize our stats arrays.
After creating an instance of our job and attaching it to an actor, we can call the Employ() function. In this function we set a reference to the actor’s Stats object and set up listeners so we can level up. As well as activate any features attached.
If we want to switch jobs, you’ll want to first call the UnEmploy() function, which will deactivate any Features, and disconnect from level up notifications and such.
When creating the character for the first time, we’ll call LoadDefaultStats() which will set all thats to the baseStat values set from the job. In addition we’ll also set current HP and MP to the max values for the character with MHP and MMP.
Job Parser Plugin
When creating the Board Creator plugin, we were adding buttons to the inspector, but this time around we will be adding a whole new tab next to the Scene tab. Just like last time lets start by creating our plugin. Go to “Project->Project Settings->Plugins” and click “Create New Plugin”
For Plugin Name “Pre Production”, and the subfolder “PreProduction”. We’ll use the suggested script name “plugin.gd”. Once we hit create the engine will create a folder in “addons” and create the files “plugin.gd” and “plugin.cfg”.
The script in plugin.gd is pretty simple. Because its a plugin we have @tool at the top, and we extend EditorPlugin instead of Node. First we create a variable named “panel” to hold the tab and we have a variable pointing to a scene file. In Godot, the editor is actually a Godot project itself, so we can extend it by adding scenes in different places. The scene we’ll be adding will be very simple as well, and just hold a single button.
In _enter_tree() we instantiate the scene and add it to a tab. The location it is loaded is defined with “DOCK_SLOT_LEFT_UR”. The Left refers to the tabs on the left side of the editor, and while we generally only see one column on the left and right sides, there are actually two available. “UR” says we’ll be using the right most column on the left side, on the top, which is the same section as the “Scene” tab.
In _exit_tree() we remove the panel and free it from memory. If we don’t do the cleanup here, each time we enable and disable the plugin, we’ll end up with orphan plugin tabs in our menu until we restart the engine.
@tool extends EditorPlugin var panel const Tool_Panel = preload("res://addons/PreProduction/Pre Production.tscn") func _enter_tree(): panel = Tool_Panel.instantiate() add_control_to_dock(EditorPlugin.DOCK_SLOT_LEFT_UR, panel) func _exit_tree(): remove_control_from_docks(panel) panel.queue_free()
Next lets create the scene file that we listed earlier. Create a new new scene in the same plugin folder as the script, “addons->PreProduction” named “Pre Production.tscn”. For the node type choose other and select the type VBoxContainer.
Open the scene and create a child node of type “Button” named “ParseJobs”. In the inspector for the button, under the field “Text” enter “Parse Jobs”
At this point, if we saved our scene and loaded the plugin, we should be able to see the “Pre Production” tab with a button labeled “Parse Jobs” The button doesn’t do anything quite yet, but that will be the next script we make.
Create a script in the same plugin folder named, “ParseJobs.gd”, and attach it to the button we created. Because this is an editor plugin, we use @tool again, and this time we’ll be extending Button. The variable path will hold the location of where we will save our Job prefabs.
@tool extends Button var path = "res://Data/Jobs/"
Whenever the button is pressed we’ll be calling the function _on_pressed(). It starts by creating the folder we set in the “path” variable. “make_dir_recursive_absolute” will create all the folders in the path that are not already created. In our case it would create both the folder “Data” and the subfolder “Jobs” if either of them were not already created. If the folders are already created it will return clean without any errors. Unless there is a file permission issue or something, it probably shouldn’t have any issues.
The two functions ParseStartingStats() and ParseGrowthStats() we pass in the return from Get_data() which will break up our .csv file into a dictionary that we can use in our functions. Each line is broken down into a single dictionary entry with with an array of each item on the line.
func _on_pressed(): var error = DirAccess.make_dir_recursive_absolute(path) if error != 0: print("Error Creating Directory. Error Code: " + str(error)) ParseStartingStats(Get_data("res://Settings/JobStartingStats.csv")) ParseGrowthStats(Get_data("res://Settings/JobGrowthStats.csv")) func Get_data(path:String): var maindata = {} var file = FileAccess.open(path,FileAccess.READ) while !file.eof_reached(): var data_set = Array(file.get_csv_line()) maindata[maindata.size()] = data_set file.close() return maindata
In the next section, we skip key “0” because that column in our spreadsheet is just the labels of the stat types. The array “elements” is just the single line in our spreadsheet for the current job we are looping on. We call GetOrCreate() to give us a PackedScene. Index “0” on our array is the string representing our Job name.
Once we have our PackedScene, we call instantiate() to get Godot to view it as a Job class. Once we have that we loop through the items in Job and load in the remaining indexes from the elements array, skipping the first one that holds the name.
Once we are finished looping though the elements stored in statOrder, we still have a couple stats left in “elements” that didn’t get used. We create StatModifierFeature objects to assign those values and attach them to our object. Like with the PackedScene, we are calling a function that will create that for us. We do this so that if there has already been an object created, we can just return the existing object instead, and we’ll get to implementing those functions shortly.
The last thing we do is call scene.pack() and ResourceSaver.save() to create the prefabs. To make these work correctly, there are a few things we have to do. First we mentioned earlier, is any variable we want saved as part of the scene, we need to add “@export”. The next main thing we need to do, which we won’t see until we get to adding Feature objects to the scene, and that is, in addition to objects being parented to the root node we add to our PackedScene, we also need to set another value, “owner” which we set to the root node for all children we want added to the scene.
func ParseStartingStats(data): for item in data.keys(): if item == 0: continue var elements : Array = data[item] var scene:PackedScene = GetOrCreate(elements[0]) var job = scene.instantiate() for i in job.statOrder.size(): job.baseStats[i] = int(elements[i+1]) var move:StatModifierFeature = GetFeature(job, StatTypes.Stat.MOV) move.amount = int(elements[8]) move.name = "SMF_MOV" var jump:StatModifierFeature = GetFeature(job, StatTypes.Stat.JMP) jump.amount = int(elements[9]) jump.name = "SMF_JMP" scene.pack(job) ResourceSaver.save(scene, path + elements[0] + ".tscn")
Once ParseStartingStats() is complete, we’ll reload the scene in ParseGrowthStats() and loop through the second spreadsheet similar to how we did the first one.
func ParseGrowthStats(data): for item in data.keys(): if item == 0: continue var elements : Array = data[item] var scene:PackedScene = GetOrCreate(elements[0]) var job = scene.instantiate() for i in job.statOrder.size(): job.growStats[i] = float(elements[i+1]) scene.pack(job) ResourceSaver.save(scene, path + elements[0] + ".tscn")
The next two functions are pretty simple. GetOrCreate() checks if a scene exists and returns it, otherwise calls Create() to create one from scratch. GetFeature() loops through the children of the job and tries to find a match for the current stat type. If it doesn’t find one, we create it. When we do, we have to make sure to set the owner of the feature to the parent Job object.
func GetOrCreate(jobName:String): var fullPath:String = path + jobName + ".tscn" if ResourceLoader.exists(fullPath): return load(fullPath) else: return Create(fullPath) func Create(fullPath:String): var job:Job = Job.new() job.name = "Job" var scene:PackedScene = PackedScene.new() scene.pack(job) ResourceSaver.save(scene, fullPath) return scene func GetFeature(job:Job, type:StatTypes.Stat): var nodeArray:Array[Node] = job.get_children() var filteredArray = nodeArray.filter(func(node):return node is Feature) for smf in filteredArray: if smf.type == type: return smf var feature:StatModifierFeature = StatModifierFeature.new() feature.type = type job.add_child(feature) feature.set_owner(job) return feature
Now that we have that script done, lets go back to the scene “Pre Production.tscn”. Select the button in the Scene Tree. In previous lessons we created signals in code, but this time we’ll do it manually. Go to the Node tab(next to the Inspector tab) and in the list under “BaseButton” select “pressed()” and click “Connect”.
In the popup, under “Receiver Method” click the button that says “Pick” and choose the function in our script named “_on_pressed()”
StatModifierFeature
Before we can run our script we need to modify one small section of our past code. Because we are attaching a StatModifierFeature to the prefabs we are saving, we need to tell the engine which variables we want saved. In this case “type” and “amount”. To do this, open up the script “StatModifierFeature.gd” and add the “@export” keyword in front of both of those variables.
@export var type:StatTypes.Stat @export var amount:int
Once you save everything at this point, and enable/re-enable the plugin we created, we should be able to click the button and have it create our job prefabs inside the folder “Data->Jobs”
Init Battle State
Now that that is done, lets make a few quick changes to our project to use the new data. With the changes, we have access to stats for Move and Jump, so in “InitBattleState.gd” we’ll replace the code in the function “SpawnTestUnits()”. Instead of looping through movement types, we’ll be looping through class types. For the time being to keep things simple, we’ll hardcode WalkMovement as the movement type for all the classes. If you want to try the other movements, just swap that line with a different movement type. In the future this will all get rolled into a factory class to build our units, but for now the focus is just generating the class data.
func SpawnTestUnits(): var jobList:Array[String] = ["Rogue", "Warrior", "Wizard"] var path = "res://Data/Jobs/" for i in jobList.size(): var unit:Unit = _owner.heroPrefab.instantiate() unit.name = jobList[i] _owner.add_child(unit) var s:Stats = Stats.new() unit.add_child(s) s.SetStat(StatTypes.Stat.LVL, 1) s.name = "Stats" var fullPath:String = path + jobList[i] + ".tscn" var scene:PackedScene = load(fullPath) var job:Job = scene.instantiate() unit.add_child(job) job.Employ() job.LoadDefaultStats() var p:Vector2i = Vector2i(_owner.board.tiles.keys()[i].x,_owner.board.tiles.keys()[i].y) unit.Place(_owner.board.GetTile(p)) unit.Match() var m = unit.get_node("Movement") m.set_script(WalkMovement) m.range = 5 m.jumpHeight = 1 m.set_process(true) units.append(unit) # var rank = Rank.new() # unit.add_child(rank) # rank.Init(10)
Movement
In “Movement.gd” we’ll change our variables to return a value from the stat object instead of holding the values directly. We’ll also need a variable to hold the stat object. Replace the variables “range” and “jumpHeight” and create another variable “stats”.
var range: int: get: return stats.GetStat(StatTypes.Stat.MOV) var jumpHeight: int: get: return stats.GetStat(StatTypes.Stat.JMP) var stats:Stats
Inside the _init() function we’ll add this line to set the stat node to the variable.
stats = get_node("../Stats")
Demo
We should have everything working now. Play the Battle scene and give it a shot. All the characters movement is set to WalkMovement as mentioned earlier. If you want to see the stats, while the scene is running, look at the scene tree and select remote. If you scroll down to one of the characters and select the “Stats” child node, you can go into the inspector and look at the “Data” variable. It won’t show the names of the variables, so you’ll have to cross reference them from the enum in “StatTypes.gd”.
Now lets stop the scene and uncomment the lines in “SpawnTestUnits()” in “InitBattleState.gd”. This will add another child to our unit to store our handle our level that we created previously, and the call to “rank.Init(10)” will set the character’s level to 10. If you go back and play the scene again, you can see how the stats have changed from the previous example.
Summary
As usual the repository will contain this lesson’s full code if you need to compare. I’ve chosen not to include the job prefabs in this commit since this lesson is about creating the job parser, so you’ll have to click the “Parse Jobs” button to create them if you’ve downloaded the version from the repo. I will however add the prefabs to later lessons to keep the game functional if it is downloaded from the repo in later lessons.