Godot Tactics RPG – 17. Hit Rate

7thSage again. Welcome back. This lesson we’ll be implementing Hit Rate. What’s the fun if every attack always works out as planned. We’ll be taking into account some effects, and whether we are attacking from the front, back or side of a unit. We’ll also be adding a bit more UI to show the new percent chances during our attack.

Facings

We already have north, south etc, but now it would be helpful to have something in relation to where the unit is looking. We don’t care which side an attack is coming from to calculate hit rate, but whether it comes from the front or back does make a difference.

In the file “Directions.gd” from the folder “Scripts->Enums Extenions”.

Lets add a new enum with the directions we need.

enum Facings{
FRONT,
SIDE,
BACK
}

We also need a function to calculate the direction.

static func GetFacing(attacker:Unit, target:Unit):
	var targetDirection:Vector2 = ToVector(target.dir)
	var attackerDirection:Vector2 = (Vector2)(attacker.tile.pos - target.tile.pos).normalized()
	
	const frontThreshold:float = 0.45
	const backThreshold:float = -0.45
	
	var dot:float = targetDirection.dot(attackerDirection)
	if dot >= frontThreshold:
		return Facings.FRONT
	if dot <= backThreshold:
		return Facings.BACK
	return Facings.SIDE

There is a few things going on here. The first thing is the calculation for attackerDirection. I opted for the opposite calculation than what the original tutorial did. If you swap the order that is subtracted you can either get the direction from the attacker to the target, or from the target to the attacker. For me it made sense doing everything from the target’s point of view. It doesn’t make much difference, just swaps which direction is negative, but to me feels more intuitive.

The second thing, at the end of the line you’ll notice a call to .normalized(). This is something you’ll run into a lot in game programming. It takes a vector facing any direction, and changes the length to be exactly one without changing the direction. The next thing in the function, dot product, another common math operation in game programming, in this example needs unit vectors(vectors with a length of 1) to work properly.

The dot product takes two vectors, and will return a single float value that compares the relationship between the two vectors. If the unit vectors are facing the same direction, dot product will return 1. Such as if our target is facing north, and the attacker is north of the target. If they are at right angles, dot product will return 0, such as our target is facing east, and the attacker is to the north. And lastly, if they are opposite directions, you’ll get negative 1. Such as our target is facing East, and our attacker is to the West. If the values are somewhere between those, you’ll get something between the results, which is what we’ll use the thresholds for, to cut off which values are going towards which direction.

The following picture shows the different values of the tiles around the target assuming it is facing north. If the target is facing a different direction, you would rotate the graph to match the target’s direction.

If the attacker is attacking from a tile that is green, the function will return Facings.FRONT. If from one of the tiles in yellow, it will return Facings.SIDE, and if red, it will return Facings.BACK. You’ll notice that the value for the diagonals is actually at approximately 0.707 and not 0.45, like our threshold values are set. Actually, its not even halfway between at “0.5”. This means that attacks will strongly favor being considered coming from either the front or back. I left a variable for each front and back though, so you can tweak the numbers to how you want your game to work. For me I think I’d leave the front at .45ish, and make the back attack a bit harder to land so at most the diagonal is included, but perhaps not even that. So maybe I’d go with the backThreshold of “-0.71”. But its all up to you, and you can use the graph to get an idea of what the values represent.

Match Exception

We’ll use this to keep track of who the Attackers and Defenders are. We extend BaseException with a default value of false. By using a BaseException instead of a toggle we could flip, we have a bit of a safety net, where we can’t flip the value to true one frame, and then back to false the next.

Create a new script named “MatchException.gd” in the folder “Scripts->Exceptions”

extends BaseException
class_name MatchException

var _attacker:Unit
var _target:Unit

var attacker:Unit:
	get:
		return _attacker
var target:Unit:
	get:
		return _target

func _init(atk:Unit, trgt:Unit):
	super(false)
	_attacker = atk
	_target = trgt

Info

In the original tutorial, the class was made very generic to handle a wider set of data to be stored. So I did a quick check and didn’t see any examples in the future lessons that would break if we locked down the first two variables to the type “Unit”, so that’s what I did. While GDScript doesn’t have templates, it does have dynamic typing, so we can just leave the type off the last variable “data” and use it to store whatever we want to pass along. I created a couple variables for users to access the attacker and target so hopefully they don’t get modified when they aren’t supposed to. The ‘data’ variable however we will be modifying directly, so I left it plain.

Create a new script named “Info.gd” in the folder “Scripts->Model”

extends Node
class_name Info

var _attacker:Unit
var _target:Unit

var data

var attacker:Unit:
	get:
		return _attacker
var target:Unit:
	get:
		return _target

func _init(unitA:Unit, unitB:Unit, arg):
	_attacker = unitA
	_target = unitB
	data = arg

Hit Success Indicator

In the “Battle.tscn” scene, as a child to the node “Battle Controller”, create a node named “Hit Success Indicator”.

Create a new script in “Scripts->View model Component” named “HitSuccessIndicator.gd” and attach it to the node we just created.

The class is fairly simple. We have a few signals that we’ll use shortly. Because our signals work a little different than the original tutorial, I needed to find a more central place to store our signals that isn’t attached directly to a single character. I decided that keeping them grouped with the hit rate code would probably be ideal, so I moved them to the Hit Success Indicator.

After that is several @export variables that will point to the nodes that hold the graphical elements. In the _ready() function we start the panel in the “Hide” position with the parameter “false” to tell it not to animate.

In SetStats(), “arrow.value” is the percentage value that our slider on TextureProgressBar uses, and the rest we’ve seen in previous UI lessons.

extends Node
class_name HitSuccessIndicator

signal AutomaticHitCheckNotification
signal AutomaticMissCheckNotification
signal StatusCheckNotification

@export var anchorList:Array[PanelAnchor] = []
@export var panel:LayoutAnchor
@export var arrow:TextureProgressBar
@export var label:Label

func _ready():
	SetPosition("Hide", false)

func SetStats(chance:int, amount:int):
	arrow.value = chance
	label.text = "{0}% {1}pt(s)".format([chance, amount])
	
func Show():
	SetPosition("Show", true)

func Hide():
	SetPosition("Hide", true)

func SetPosition(anchorName:String, animated:bool):
	var anchor = GetAnchor(anchorName)
	await panel.ToAnochorPosition(anchor, animated)	
	
func GetAnchor(anchorName: String):
	for anchor in self.anchorList:
		if anchor.anchorName == anchorName:
			return anchor
	return null

Next up, lets create a child node under “Hit Success Indicator” of type “Panel” and name it “Hit Panel”. To that node, attach the script that we created in an earlier lesson, “LayoutAnchor.gd”.

In the inspector, under “Layout”, set the “Anchors Preset” to “Center Bottom”. Set the size to x:192, y:107 and set the Position to x:480, y:401. The last thing we need to set for this node is to get rid of the gray transparency. Go down to “Theme Overrides” and set the Styles to “StyleBoxEmpty”

As a child of “Hit Panel” we need to create two nodes. The first, a node of type “TextureProgressBar” that we’ll name “Hit ProgressBar”, and a second of type “Label” that we’ll name “Hit Label”.

In the inspector for “Hit ProgressBar”, in the section “Textures” we need to set two textures, for “Under” choose load, and set the texture to “Textures->UI->AttackArrowBacker.png”, and for “Progress” set the texture to “Textures->UI->AttackArrowFill.png”. Set the “Progress Offset” to x:14, y:3.

Under the section “Range” we shouldn’t have to change anything, Min Value should be 0, and Max Value should be 100. “Value” is the variable that the Hit Success Indicator accessed to set the slider position.

A bit further down in the section “Layout” set the Anchor Preset to “Bottom Left”. and under “Transform”, set the size to x:192, y 81, and the position to x:0, y:-10.

For the “Hit Label” node, we’ll need to dig down into a bit to get to all the settings we need. To start off lets give it some default text in the field “Text”, so we can see what we are creating, something like “77% 7pt(s)”. Next up under “Label Settings” create a “New LabelSettings” and then expand it to see the settings.

In the drop down for Font, under the property “Font” choose, “New SystemFont” and click on it to expand.

Under “Font Names” click where it says “PackedStringArray” to expand that as well, and click on the button “Add Element” and choose Arial.

Now finally we can get to the settings we need for the font. Under “Font Italic” check the box to “On”. Under “Font Weight”, set the value to 575

You can collapse the SystemFont settings now. Back in Label Settings->Font, under “Size” set to 30px, and for color we’re using Hex#912229.

Under Outline, set the Size to 6px, and the color to Hex#00000080.

Just below the Label Settings, set the Horizontal and Vertical Alignments to “Center”

A bit further down under “Layout” set the Layout Mode to Anchors and the Preset to “Center Bottom”. For the Transform set the size to x:168, y:49 and the Position to x:12, y:58

Now that all the nodes are created, lets go back up to the “Hit Success Indicator” node and in the inspector, set the variables for Panel, Arrow and Label to their respective Nodes, and under “Anchor List” create two elements.

For the first one set the values to
Anchor Name: Hide
My Anchor: Preset Center Top
Parent Anchor: Preset Center Bottom
Offset: x:0, y:15
Trans: Trans Quad

For the second one set the values to
Anchor Name: Show
My Anchor: Preset Center Bottom
Parent Anchor: Preset Center Bottom
Offset: x:0, y:-130
Trans: Trans Quad

Hit Rate

Create a new folder in “Scripts->View Model Component->Ability” named “Hit Rate” and once you have the folder created, create a script named “HitRate.gd”

This will be our base class for the various types of skills. In our case there will be three. One for the standard attack, another for applying status ailments, and the last for abilities that always hit.

extends Node
class_name HitRate

var hitIndicator:HitSuccessIndicator

func _ready():
	hitIndicator = get_node("/root/Battle/Battle Controller/Hit Success Indicator")

func Calculate(attacker:Unit, target:Unit):
	pass

func AutomaticHit(attacker:Unit, target:Unit)->bool:
	var exc:MatchException = MatchException.new(attacker, target)
	hitIndicator.AutomaticHitCheckNotification.emit(exc)
	return exc.toggle

func AutomaticMiss(attacker:Unit, target:Unit)->bool:
	var exc:MatchException = MatchException.new(attacker, target)
	hitIndicator.AutomaticMissCheckNotification.emit(exc)
	return exc.toggle

func AdjustForStatusEffects(attacker:Unit, target:Unit, rate:int)->int:
	var args:Info = Info.new(attacker, target, rate)
	hitIndicator.StatusCheckNotification.emit(args)
	return args.data

func Final(evade:int):
	return 100 - evade

Because the signals need to be accessable from multiple units, I moved them to the hit indicator, because of this we need a reference to that, which I grab in _ready(), and then we emit the signal here in AutomaticHit, AutomaticMiss and AdjustForStatusEffects.

The different subclasses will reuse many of the same checks, but they may be called in a different order, or omitted. If we perform an Attack on a unit, we need to consider its evade, but if the unit is frozen or asleep, we can skip the check and use AutomaticHit().

On the other side, if an attack is sure to miss, such as a boss with immimunity to status effects, we could call the AutomaticMiss()

At the end we look at all the results that may have modified our evade chances, and return a final chance to hit, minus the evade.

A-Type Hit Rate

Our first subclass will likely be the one we use most often. With this, after we check for automatic hit and miss, we look at the target’s EVD(evade) stats.

In the same folder create a script named “ATypeHitRate.gd”

extends HitRate
class_name ATypeHitRate

func Calculate(attacker:Unit, target:Unit):
	if(AutomaticHit(attacker, target)):
		return Final(0)
	
	if(AutomaticMiss(attacker, target)):
		return Final(100)
		
	var evade:int = GetEvade(target)
	evade = AdjustForRelativeFacing(attacker, target, evade)
	evade = AdjustForStatusEffects(attacker, target, evade)
	evade = clamp(evade, 5, 95)
	return Final(evade)

func GetEvade(target:Unit):
	var s:Stats = target.get_node("Stats")
	return clamp(s.GetStat(StatTypes.Stat.EVD), 0, 100)

func AdjustForRelativeFacing(attacker:Unit, target:Unit, rate:int):
	match Directions.GetFacing(attacker, target):
		Directions.Facings.FRONT:
			return rate
		Directions.Facings.SIDE:
			return rate/2
		_:
			return rate/4

S-Type Hit Rate

The next subclass is similar, though this one is more for status effects. Instead of Looking at EVD values, we’re looking at RES(resistance). We also have the AutomaticHit and AutomaticMiss() in a different order.

In the same folder create a script named “STypeHitRate.gd”

extends HitRate
class_name STypeHitRate

func Calculate(attacker:Unit, target:Unit):
	if(AutomaticMiss(attacker, target)):
		return Final(100)	
	
	if(AutomaticHit(attacker, target)):
		return Final(0)
		
	var res:int = GetResistance(target)
	res = AdjustForStatusEffects(attacker, target, res)
	res = AdjustForRelativeFacing(attacker, target, res)
	
	res = clamp(res, 0, 100)
	return Final(res)

func GetResistance(target:Unit):
	var s:Stats = target.get_node("Stats")
	return clamp(s.GetStat(StatTypes.Stat.RES), 0, 100)

func AdjustForRelativeFacing(attacker:Unit, target:Unit, rate:int):
	match Directions.GetFacing(attacker, target):
		Directions.Facings.FRONT:
			return rate
		Directions.Facings.SIDE:
			return rate - 10
		_:
			return rate - 20

Full Type Hit Rate

Our last type will normally hit without fail, but we leave a check in case there is an exception. This one we aren’t worried about the EVD or RES values, so once we are done checking if there is an automatic miss, we return the final value.

In the same folder create a script named “FullTypeHitRate.gd”

extends HitRate
class_name FullTypeHitRate

func Calculate(attacker:Unit, target:Unit):	
	if(AutomaticMiss(attacker, target)):
		return Final(100)
		
	return Final(0)

Stop Status Effect

Now that we can are able to miss attacks, it makes sense to add it to our Stop status, making it so attacking the unit will result in an automatic hit. Open up “StopStatusEffect.gd”, we’ll need to make a few small changes.

First, lets add a variable to reference our Hit Indicator. At the top of the script with any other variables, add the line.

var hitIndicator:HitSuccessIndicator

Next in _enter_tree() we need to grab the reference to the Hit Indicator and connect the listener for Automatic Hits.

hitIndicator = get_node("/root/Battle/Battle Controller/Hit Success Indicator")
if hitIndicator:
	hitIndicator.AutomaticHitCheckNotification.connect(OnAutomaticHitCheck)

Whenever AutomaticHit() is called in a Hit Rate, we check the next functions we need to add. The OnAutomaticHitCheck() starts off getting the Unit the status effect is attached to. I created a small recursive function to go up the tree until it finds a node of type Unit. This way whe’ll be able to move the node around a little later, and it will still work.

After we have the Unit, we check if the unit is the target and if it is, we flip the toggle, which will set the value to true, meaning it’s an automatic hit. The signal will go though all the other status effects and units, so its important that we check that we have the right unit.

func OnAutomaticHitCheck(exc:MatchException):
	var _owner:Unit = GetParentUnit(self)
	if _owner == exc.target:
		exc.FlipToggle()

func GetParentUnit(node:Node):
	var parent = node.get_parent()
	if parent == null:
		return null
	if parent is Unit:
		return parent
	return GetParentUnit(parent)

Blind Status Effect

We didn’t add Blind earlier because it didn’t make any sense until we added the ability to miss. This one instead of making a hit an automatic hit or miss, this changes the odds of hitting or dodging, depending on whether its the attacker or defender that has the status applied. So this time, we’ll be listening for the signal StatusCheckNotification instead. Here we’ll be able to modify the EVD/RES values. We’re passing an object of the type we created earlier of type Info. The “data” field in this case is what is sent in Hit Rate in AdjustForStatusEffects(). In A-Type its EVD, and S-Type its RES

Create a new script “BlindStatusEfect.gd” in the folder “Scripts->View Model Component->Status->Effects”

extends StatusEffect
class_name BlindStatusEffect

var hitIndicator:HitSuccessIndicator

func _enter_tree():
	hitIndicator = get_node("/root/Battle/Battle Controller/Hit Success Indicator")
	if hitIndicator:
		hitIndicator.StatusCheckNotification.connect(OnHitRateStatusCheck)
	
func _exit_tree():
	hitIndicator.StatusCheckNotification.disconnect(OnHitRateStatusCheck)

func OnHitRateStatusCheck(info:Info):
	var _owner:Unit = GetParentUnit(self)
	if _owner == info.attacker:
		info.data = info.data + 50
	elif _owner == info.target:
		info.data = info.data - 20

func GetParentUnit(node:Node):
	var parent = node.get_parent()
	if parent == null:
		return null
	if parent is Unit:
		return parent
	return GetParentUnit(parent)

Job Parser

Lets start by updating our spreadsheet “JobStartingStats.csv” in the folder “Settings”. You’ll have to open it in an external editor. You can right click on the file and say “Show in File Manager”, and from there you can open the file in your text editor of choice. Should note that we still have the MOV and JMP stats at the end of the list, so their order will change when we grab them in the parser. Also, be careful that you don’t add any blank lines at the end which will cause issues with Godot.

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

When we first created the Jobs, we didn’t have a stat for EVD and RES, so lets take the time to modify our Job Parser script. Open the script “ParseJobs.gd” from the folder “addons->PreProduction”

The changes are limited to the function ParseStartingStats() We’re adding two more types, and the index value for MOV and JMP are changing.

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 evade:StatModifierFeature = GetFeature(job, StatTypes.Stat.EVD)
		evade.amount = int(elements[8])
		evade.name = "SMF_EVD"
		
		var res:StatModifierFeature = GetFeature(job, StatTypes.Stat.RES)
		res.amount = int(elements[9])
		res.name = "SMF_RES"
		
		var move:StatModifierFeature = GetFeature(job, StatTypes.Stat.MOV)
		move.amount = int(elements[10])
		move.name = "SMF_MOV"
		
		var jump:StatModifierFeature = GetFeature(job, StatTypes.Stat.JMP)
		jump.amount = int(elements[11])
		jump.name = "SMF_JMP"
		
		scene.pack(job)
		ResourceSaver.save(scene, path + elements[0] + ".tscn")

Once you have both files modified and saved, go into the “Pre Production” Tab that we created next to the tab “Scene” and click the button “Parse Jobs”.

Battle Controller

Just a couple quick changes to our battle controller. First, open up the script “BattleController.gd” and with the other @export variables, add the line

@export var hitSuccessIndicator:HitSuccessIndicator

Next, let’s go to our main “Battle.tscn” scene, and in the scene view select the Battle Controller and in the inspector assign the Hit Indicator to our new variable.

Battle State

Another quick change in “BattleState.gd”, along with the other variables, add one for the Hit Indicator

var hitSuccessIndicator:HitSuccessIndicator:
	get:
		return _owner.hitSuccessIndicator

Confirm Ability Target State

Currently we are still missing one thing, we aren’t actually displaying the panel. We’ll add it to the Confirm Ability Target State. Open up the script “ConfirmAbilityTargetState.gd”. We have several changes we need to add to this script. We’ll start in the Enter() function. Let’s replace the line “SetTarget()” with the following, instead of calling it directly.

if turn.targets.size() > 0:
	await hitSuccessIndicator.Show()
	SetTarget(0)

Next in the Exit() function at the end, we need to add a call to hide the panel.

await hitSuccessIndicator.Hide()

Inside the function SetTarget(), we need to add the line “UpdateHitSuccessIndicator()” where we refresh the stat panel

if turn.targets.size() > 0:
	RefreshSecondaryStatPanel(turn.targets[index].pos)
	UpdateHitSuccessIndicator()

Next we need to add a couple new functions to handle calculating the Hit Rate. EstimateDamage() is just a placeholder for now. In the future it will return a real value.

func UpdateHitSuccessIndicator():
	var chance:int = CalculateHitRate()
	var amount:int = EstimateDamage()
	hitSuccessIndicator.SetStats(chance, amount)

func CalculateHitRate():
	var target = turn.targets[index].content
	var children:Array[Node] = turn.ability.find_children("*", "HitRate", false)
	if children:
		var hr:HitRate = children[0]
		return hr.Calculate(turn.actor, target)
	print("Couldn't find Hit Rate")
	return 0
	
func EstimateDamage()->int:
	return 50

Hero Prefab

Each skill that a unit uses will need one of the Hit Rate scripts attached. Open up the prefab scene, “Hero.tscn”.

Under the node “Attack” create a new child node of type Node and name it “Hit Rate”. Attach the script “ATypeHitRate.gd”. Save the scene.

Demo

Now, back in our main scene, “Battle.tscn” everything should be working and if you try attacking from different directions, you should get different percentages showing up. Also if you attach the script from the previous lesson, or still have it attached, we should also see how Stop affects the targeting. As another quick test, lets add a little bit more to our test script so we can see the Blind effect as well. I modified the match statement as follows. I also set the duration a bit longer for Blind and Stop so we can see the character with them a bit longer. Remember that the duration is in ticks, not the number of times a character has their turn.

match step:
	0:
		EquipCursedItem(target)
	1:
		Add(target, "Slow", SlowStatusEffect, 15 )
	2:
		Add(target, "Stop", StopStatusEffect, 150 )
	3:
		Add(target, "Haste", HasteStatusEffect, 15)
	4:
		Add(target, "Blind", BlindStatusEffect, 500)
	_:
		UnEquipCursedItem(target)

Summary

We had to get a little bit into math this time, but hopefully it wasn’t too bad. Dot Products are one of the more widely used bits of math that you’ll run into in games, so its good to have under our belt. We can now attack from different sides of a character and get a different chance to hit. We also added a new Status, Blind, that directly impacts our hit rate. We’ve also got another UI element, getting us closer to the final look of our battle.

As always, if you are having trouble, feel free ta ask questions below, or check out the repository to compare your code.

3 thoughts on “Godot Tactics RPG – 17. Hit Rate

  1. Glad you are enjoying the series. I’m working on writing the next lesson up so it shouldn’t be too much longer. Code is all done, just need to make sure I’m keeping everything straight and not missing anything. Pulling a few things from the lesson after so there is a little less refactoring overall.

Leave a Reply

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