Godot Tactics RPG – 09. Stats

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.

3 thoughts on “Godot Tactics RPG – 09. Stats

  1. 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.

    1. 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.

      1. 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!

Leave a Reply

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