7thSage again. This time we’re going to start laying some groundwork for our stats, focusing primarily on Level and Experience for the time being.
Notifications
In the original tutorial, Jon created his own notification center, but I think for this, I’m going to go with the standard Godot signals. I haven’t benchmarked signals, but I have a feeling we’ll be hard pressed to make something similar in GDScript that performs as well. If we were working in CSharp, it might be a different story, but for now, I think that’s the decision that makes sense. I also like being able to send objects directly, and not worrying about converting strings. The biggest drawback is that I don’t have a way to have a listener choose between listening to a single unit’s stat, or that specific stat for all units, which we do in our little test managing the team’s experience at the end.
Base Exception
Not to be confused with an exception in the computer error sense, an exception here is referring to an how we might modify the normal rules of our stats, such as if we normally do X damage, except we have a strength buff or debuff applied to our character.
Instead of making each system keep track of the potentially hundreds of locations that stats may change, we’ll create a system where we can watch the stat for potential changes and have it notify us when there is something modifying it.
The simplest exception, where something is normally allowed, but for some reason is blocked.
Create a new folder under “Scripts” named “Exceptions” and in that folder create a new script named “BaseException.gd”
The script is fairly simple, in the _init() constructor, we set the default bool, which depending on what we need could be true or false, then we create a function to flip it if we need that exception.
extends Node class_name BaseException var toggle:bool var defaultToggle:bool func _init(default:bool): defaultToggle = default toggle = defaultToggle func FlipToggle(): toggle = !defaultToggle
Modifiers
Beyond just needing to know whether something is allowed or not, we’ll also want to know how something might change. For instance, if we have a magic ring that raises our attack by 10 points or maybe a suit of armor that doubles experience gained.
In addition to how much our stat is changed, whether it be a percent or an added bonus, its important to think about when to apply the bonus. For example, depending on the order we place numbers inside parenthesis, we can get very different results in our math.
532 * (0 + 10) = 5,320
(532 * 0) + 10 = 10
So while we want multiple changes to potentially apply to our stats, we also want to be able to control the order in which they happen. If you’ve seen damage calculations used in some RPGs, the steps to get to the final result could take over a dozen steps and include everything from equipment, buffs/debuffs, zodiac symbol, blood type, weapon type among many more that could all possibly impact a single roll.
The method we’ll implement will store each modifier in an array and give each modifier a value for sort order, which we’ll use to sort the array and apply each modifier in order.
Create a subfolder in “Scripts->Exceptions” named “Modifiers” and inside that folder we’ll create a script named “Modifier.gd” that will be our base modifier class. This will hold the sort order that we’ll use in all the other modifiers.
extends Node class_name Modifier var sortOrder:int func _init(order:int): sortOrder = order
Extending this class will be one other class that will be what all the exceptions we create today will inherit from. Create a new script in the same folder named “ValueModifier.gd”. This will be for exceptions that modify a value, but in the future there may be other types of exceptions.
extends Modifier class_name ValueModifier func _init(sortOrder:int): super(sortOrder) func Modify(fromValue:float, toValue:float): pass
Next up we create the individual exceptions for the various mathmatical options. Add, Multiply(multiplies total), Multiply Delta(multiplies the value being changed), and a couple functions to clamp the values to either within a range, or to min/max. We’ll create all these scripts in the same “Scripts->Exceptions->Modifiers” folder.
Create a script named “AddValueModifier.gd”
extends ValueModifier class_name AddValueModifier var _toAdd:float func _init(sortOrder:int, toAdd:float): super(sortOrder) _toAdd = toAdd func Modify(fromValue:float, toValue:float)->float: return toValue + _toAdd
Create a script named “MultValueModifier.gd”
extends ValueModifier class_name MultValueModifier var _toMultiply:float func _init(sortOrder:int, toMultiply:float): super(sortOrder) _toMultiply = toMultiply func Modify(fromValue:float, toValue:float)->float: return toValue * _toMultiply
Create a script named “MultDeltaModifier.gd”
extends ValueModifier class_name MultDeltaModifier var _toMultiply:float func _init(sortOrder:int, toMultiply:float): super(sortOrder) _toMultiply = toMultiply func Modify(fromValue:float, toValue:float)->float: var delta:float = toValue - fromValue return fromValue + delta * _toMultiply
Create a script named “ClampValueModifier.gd”
extends ValueModifier class_name ClampValueModifier var _min:float var _max:float func _init(sortOrder:int, min:float, max:float ): super(sortOrder) _min = min _max = max func Modify(fromValue:float, toValue:float)->float: return clamp(toValue, _min, _max)
Create a script named “MaxValueModifier.gd”
extends ValueModifier class_name MaxValueModifier var _max:float func _init(sortOrder:int, max:float ): super(sortOrder) _max = max func Modify(fromValue:float, toValue:float)->float: return max(toValue, _max)
Create a script named “MinValueModifier.gd”
extends ValueModifier class_name MinValueModifier var _min:float func _init(sortOrder:int, min:float ): super(sortOrder) _min = min func Modify(fromValue:float, toValue:float)->float: return min(toValue, _min)
Value Change Exception
Here we create our first concrete extension of BaseException. This will hold a list of some of the modifiers we just created to create exceptions, or changes to values when we need them. Create a script named “ValueChangeException.gd” in the folder “Scripts->Exceptions”.
extends BaseException class_name ValueChangeException var _fromValue:float var _toValue:float var delta:float : get: return _toValue - _fromValue var modifiers:Array[ValueModifier] = [] func _init(fromValue:float, toValue:float): super(true) _fromValue = fromValue _toValue = toValue func AddModifier(m:ValueModifier): modifiers.append(m) func GetModifiedValue()->float: if(modifiers.size() == 0): return _toValue var value = _toValue modifiers.sort_custom(Compare) for modifier in modifiers: value = modifier.Modify(_fromValue, value) return value func Compare(a:ValueModifier, b:ValueModifier): return a.sortOrder < b.sortOrder
Stat Types
Most RPGs have a number of stats. We’ll start with a list of fairly standard ones and store these in an enum. Later we can add and remove from the list as needed. Each game will likely have a different set of stats, and the formulas that use them will vary.
Create a script named “StatTypes.gd” in the folder “Scripts->Enums Exetentions”
class_name StatTypes enum Stat { LVL, # Level EXP, # Experience HP, # Hit Points MHP, # Max Hit Points MP, # Magic Points MMP, # Max Magic Points ATK, # Physical Attack DEF, # Physical Defense MAT, # Magic Attack MDF, # Magic Defense EVD, # Evade RES, # Status Resistance SPD, # Speed MOV, # Move Range JMP, # Jump Height Count }
Stat Component
We will add a stat object to any character, monster or thing that needs to have stats. Whether that be how many hits a door can take, how many HP a character has, or the number of tiles a unit can move.
Create a folder named “Actor” in the folder “Scripts->View Model Component” and inside the folder create a script named “Stats.gd”
We start off creating a variable to hold a list of all the stats, _data, as well as a couple dictionaries that will hold our signals for each stat type. In the _init() constructor function, we resize our array to match the number of stats and zero out all the values. The signals we’ll create on the fly as we need each one.
extends Node class_name Stats var _data:Array[int] = [] var _willChangeNotifications = {} var _didChangeNotifications = {} func _init(): _data.resize(StatTypes.Stat.size()) _data.fill(0)
We have a set and get function to deal with whatever stat we are looking for, and use the same logic on all stats. The setter first checks if there is any change in the value, and if not returns early. Next if exceptions are allowed, it will create an exception and post a ‘will change’ notification. Next we check if the change was undone somewhere and finally we set the value to the variable, and call the ‘did change’ notification.
func GetStat(statType:StatTypes.Stat): return _data[statType] func SetStat(statType:StatTypes.Stat, value:int, allowExceptions:bool = true): var oldValue:int = _data[statType] if oldValue == value: return if allowExceptions: # Allow exceptions to the rule here var exc:ValueChangeException = ValueChangeException.new(oldValue,value) # The notification is unique per stat type WillChangeNotification(statType).emit(self, exc) # Did anything modify the value? value = floori(exc.GetModifiedValue()) # Did something nullify the change? if exc.toggle == false || value == oldValue: return _data[statType] = value DidChangeNotification(statType).emit(self, oldValue)
When the setter calls the notification, the corresponding dictionary is checked if it has the value and returns if it does, otherwise it will create and add the notification to the dictionary.
func WillChangeNotification(statType:StatTypes.Stat): var statName = StatTypes.Stat.keys()[statType] if(!_willChangeNotifications.has(statName)): self.add_user_signal(statName+"_willChange") _willChangeNotifications[statName] = Signal(self, statName+"_willChange") return _willChangeNotifications[statName] func DidChangeNotification(statType:StatTypes.Stat): var statName = StatTypes.Stat.keys()[statType] if(!_didChangeNotifications.has(statName)): self.add_user_signal(statName+"_didChange") _didChangeNotifications[statName] = Signal(self, statName+"_didChange") return _didChangeNotifications[statName]
Rank
We’ll add a rank component to our heroes. This will control how EXP(Experience) and LVL(Level) stats interact with each other. We define the curve with ease(levelPercent, 2.0). The 2.0 in the second parameter defines the shape of the curve. This value is the same as Unity’s EaseInQuad. If you’d like to know what curve the other values give, check out this Image from the Godot Documentation.
Add another script named “Rank.gd” to the folder “Scripts->View Model Component->Actor”
extends Node class_name Rank const minLevel:int = 1 const maxLevel:int = 99 const maxExperience:int = 999999 var stats:Stats var LVL:int: get: return stats.GetStat(StatTypes.Stat.LVL) var EXP:int: get: return stats.GetStat(StatTypes.Stat.EXP) set(value): stats.SetStat(StatTypes.Stat.EXP, value) var levelPercent:float: get: return (float)(LVL - minLevel) / (float)(maxLevel - minLevel) func _ready(): stats = get_node("../Stats") stats.WillChangeNotification(StatTypes.Stat.EXP).connect(OnExpWillChange) stats.DidChangeNotification(StatTypes.Stat.EXP).connect(OnExpDidChange) func _exit_tree(): stats.WillChangeNotification(StatTypes.Stat.EXP).disconnect(OnExpWillChange) stats.DidChangeNotification(StatTypes.Stat.EXP).disconnect(OnExpDidChange) func OnExpWillChange(sender:Stats, vce:ValueChangeException): vce.AddModifier(ClampValueModifier.new(999999, EXP, maxExperience)) func OnExpDidChange(sender:Stats, oldValue:int): stats.SetStat(StatTypes.Stat.LVL, LevelForExperience(EXP), false) static func ExperienceForLevel (level: int )->int: var levelPercent = clamp( (float)(level - minLevel) / (float)(maxLevel - minLevel), 0, 1) return (int)(maxExperience * ease(levelPercent, 2.0)) static func LevelForExperience(exp:int)->int: var lvl = maxLevel while lvl >= minLevel: if(exp >= ExperienceForLevel(lvl)): break lvl-= 1 return lvl func Init(level:int): stats.SetStat(StatTypes.Stat.LVL, level, false) stats.SetStat(StatTypes.Stat.EXP, ExperienceForLevel(level), false)
Experience Manager
There are many different ways to award experience. Here we’ll focus on creating a sharded pool, that is divided up, for instance at the end of battle. We can do several things to modify these values, such as a unit that is KO’d may not recieve any experience at all, while a lower level unit may recieve a larger share. We set min and max bonuses to make sure everyone gets at least some experience.
Create a script named “ExperienceManager.gd” in the folder “Scripts->Controller”
extends Node class_name ExperienceManager const minLevelBonus:float = 1.5 const maxLevelBonus:float = 0.5 static func AwardExperience(amount:int, party:Array[Node]): # Grab a list of all of the rank components from our hero party var ranks:Array[Rank] = [] for unit in party: var r:Rank = unit.get_node("Rank") if(r != null): ranks.append(r) # Step 1: determine the range in actor level stats var min:int = 999999 var max:int = -999999 for rank in ranks: min = min(rank.LVL, min) max = max(rank.LVL, max) # Step 2: weight the amount to award per actor based on their level var weights:Array[float] = [] weights.resize(ranks.size()) var summedWeights:float = 0 for i in ranks.size(): var percent:float = (float)(ranks[i].LVL - min + 1) / (float)(max - min + 1) weights[i] = lerp(minLevelBonus, maxLevelBonus, percent) summedWeights += weights[i] # Step 3: hand out the weighted award for i in ranks.size(): var subAmount:int = floori((weights[i] / summedWeights) * amount) ranks[i].EXP += subAmount
Test & Demo
The last thing left to do is create a small demo script to test everything that we’ve done. We do two small simulations, that we start in the _ready() function. The first VerifyLevelToExperienceCalculations() loops though all the levels and returns the experience required for each level. The next VerifySharedExperienceDistribution() simulates receiving experience after a battle and splitting it among the party based on their level difference. For this example I decided to give the characters here each names as a small homage to the genre.
Create a new script named “TestLevelGrowth.gd” and place it in whatever folder you have reserved for test scripts. In the Battle scene, create a “Test” node if you don’t have one and attach the script to it. Once we are done, we’ll delete the node, or at least remove the script.
extends Node var heroes:Array[Node] = [] var _random = RandomNumberGenerator.new() func _ready(): _random.randomize() VerifyLevelToExperienceCalculations() VerifySharedExperienceDistribution() func _exit_tree(): for actor in heroes: var stats:Stats = actor.get_node("Stats") stats.DidChangeNotification(StatTypes.Stat.LVL).disconnect(OnLevelChange) stats.WillChangeNotification(StatTypes.Stat.EXP).disconnect(OnExperienceException) func VerifyLevelToExperienceCalculations(): for i in range(1,100): var expLvl:int = Rank.ExperienceForLevel(i) var lvlExp:int = Rank.LevelForExperience(expLvl) if(lvlExp != i): print("Mismatch on level:{0} with exp:{1} returned:{2}".format([i, expLvl, lvlExp])) else: print("Level:{0} = Exp:{1}".format([lvlExp, expLvl])) func VerifySharedExperienceDistribution(): var party:Array[String] = [ "Ramza", "Bowie", "Marth", "Ike", "Delita", "Max" ] for hero in party: var actor:Node = Node.new() actor.name = hero self.add_child(actor) var stats:Stats = Stats.new() stats.name = "Stats" actor.add_child(stats) stats.DidChangeNotification(StatTypes.Stat.LVL).connect(OnLevelChange) stats.WillChangeNotification(StatTypes.Stat.EXP).connect(OnExperienceException) var rank:Rank = Rank.new() rank.name = "Rank" actor.add_child(rank) rank.Init(_random.randi_range(1, 4)) heroes.append(actor) print("===== Before Adding Experience ======") LogParty(heroes) print("=====================================") ExperienceManager.AwardExperience(1000, heroes) print("===== After Adding Experience ======") LogParty(heroes) func LogParty(party:Array[Node]): for actor in party: var rank = actor.get_node("Rank") print("Name:{0} Level:{1} Exp:{2}".format([actor.name, rank.LVL, rank.EXP])) func OnLevelChange(sender:Stats, oldValue:int): var actor:Node = sender.get_parent() print(actor.name + " leveled up!") func OnExperienceException(sender:Stats, vce:ValueChangeException): var actor:Node = sender.get_parent() var roll:int = _random.randi_range(0, 4) match roll: 0: vce.FlipToggle() print("{0} would have received {1} experience, but we stopped it".format([actor.name, vce.delta])) 1: vce.AddModifier(AddValueModifier.new(0,1000)) print("{0} would have received {1} experience, but we added 1000".format([actor.name, vce.delta])) 2: vce.AddModifier(MultDeltaModifier.new(0,2)) print("{0} would have received {1} experience, but we multiplied by 2".format([actor.name, vce.delta])) _: print("{0} will receive {1} experience".format([actor.name, vce.delta]))
Now press play and look at the output. We should see a list of how much experience needed for each level, followed by a short example of experience points getting divided among the characters with various exceptions added to modify the values. Once you are done, don’t forget to remove the test object from the scene.
Summary
As always, if you have any questions, feel free to ask, or you can compare the code in the repository.
While this was a shorter lesson, there was a lot to get through before we could see something on the screen. We’re working our way toward getting all the pieces we need to add to our characters.
I’m loving this series. Thank you.
but I would to say, that IMO you are following liquidfire’s tedency to overengineer simple solutions like this stats system.
for example, why not make a single stat modifier script that you can shoose the type of modifier from an enum, or use clamp() in the stat script instead of a separate min/max script.
you can make a very robust stat system with Resources and a couple of scripts .
that can make it harder to follow along for less experienced devs.
Glad you are enjoying the series. I’ve tried my best to walk a fine line with the tutorial, and do my best to keep it consistent with the original design ethos. While I have at times made some small tweaks, or extended bits, I’ve tried to toe that line. There are a few reasons for this. The most simple is that if I change too much, it wouldn’t make sense to be here, it would be its own tutorial, which would be fine, but I’m not sure I’d be able to pull off something as fully featured if I was creating my own. But I also want to keep it recognizable with the original for a few reasons. One of the biggest is that if someone made tweaks or had ideas with the original, they’d be able to still do something similar with hopefully minimal effort. The other main reason, is that if at any point someone decides I am too slow, or if something happens to me, they can still continue the tutorial on their own, building up based on the Unity version as I have. It takes a bit of work thinking through some things, but honestly if I can pull it off, just about anyone should be able to, I’m really not the most experienced dev. I’d even almost recommend people try. I’ve learned so much, both about Godot and gained a much deeper understanding of the code here as well. There are still things I don’t completely understand, but I’m learning and having fun, so that’s what mostly matters. I just hope people aren’t too annoyed at my slow progress.
Perfect response, and I think you are absolutely right. In my opinion, the one who benefits the most from writing a tutorial is the one who wrote it. You learn so much by teaching and doing things on your own. Not only do you have to make things work, but you have to be able to say why you chose to do it a certain way. It can be very rewarding to branch out and do things on your own like this. I also hope more people try!