Godot Tactics RPG – 12. Stat Panel

7thSage again. This time we’ll be creating some more UI elements. We’ve been working with stats for a few lessons now, and its time we created something to show at least some of them. We’ll create a pair of panels to show the character’s name, level, HP and MP values. We’ll be mostly using the primary panel for now. The secondary panel we’ll use later for the target of an action.

Stat Panel

In the folder “Scripts->View Model Component”, create a new script named “StatPanel.gd”. We’ll extend LayoutAnchor in this one so we can add the positions to move the panel on and off screen to later. The main variables in this script will hold references to the relevant nodes that we will be accessing to display our data. We also need to store the two possible background color options which will differentiate enemies and allies later on, although for now the color will be randomly picked each time a panel is displayed.

extends LayoutAnchor
class_name StatPanel

@export var allyBackground:Texture2D
@export var enemyBackground:Texture2D
@export var background:NinePatchRect
@export var avatar:TextureRect
@export var nameLabel:Label
@export var hpLabel:Label
@export var mpLabel:Label
@export var lvLabel:Label

Next up we have one last variable to add to the list. This will hold our anchor points. And two functions that we’ve seen before to handle the position of the panels.

@export var anchorList:Array[PanelAnchor] = []

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

The last function in this class is what will update the panel’s information and set the random background.

func Display(obj:Node):
	#Temp until later lesson when we add a component to determine unit alliances
	background.texture = [allyBackground, enemyBackground].pick_random()
	#avatar.texture = #Need a component which provides this data
	nameLabel.text = obj.name
	var stats:Stats = obj.get_node("Stats")
	if stats:
		hpLabel.text = "HP {0} / {1}".format([stats.GetStat(StatTypes.Stat.HP), stats.GetStat(StatTypes.Stat.MHP)])
		mpLabel.text = "MP {0} / {1}".format([stats.GetStat(StatTypes.Stat.MP), stats.GetStat(StatTypes.Stat.MMP)])
		lvLabel.text = "LV. {0}".format([stats.GetStat(StatTypes.Stat.LVL)])

Stat Panel Controller

Lets create another script in the folder “Scripts->Controller” named “StatPanelController.gd”. We’ll use this script to manage the two panels we create. The two constants for ShowKey and HideKey will store the text string we add to the anchor points on each panel. Then two variables to hold references to the panels, and a bool for each panel to keep track of whether the panel is visible. This will let us prevent multiple calls to the animation moving the panel on or off the screen which would cause issues with how the animation is played.

In _ready() we hide both panels offscreen disabling the animation in each call, along with setting the initial values for our variables. In ShowPrimary() and ShowSecondary() respectively, we call the Display() function on the panel to update the information in the panel, and if the panel is not already in the visible position, will move the panel onscreen.

HidePrimary() and HideSecondary() are similar. We check if the panels are offscreen, and if not, we move them off. The panels don’t get updated until we are ready to move them back on screen later.

extends Node
class_name StatPanelController

const ShowKey:String = "Show"
const HideKey:String = "Hide"

@export var primaryPanel:StatPanel
@export var secondaryPanel:StatPanel

var primaryShowing:bool
var secondaryShowing:bool

func _ready():
	primaryPanel.ToAnochorPosition(primaryPanel.GetAnchor(HideKey), false)
	primaryShowing = false
	secondaryPanel.ToAnochorPosition(secondaryPanel.GetAnchor(HideKey), false)
	secondaryShowing = false
	
func ShowPrimary(obj:Node):
	primaryPanel.Display(obj)
	if primaryShowing == false:
		primaryShowing = true
		await primaryPanel.SetPosition(ShowKey,true)
	
func HidePrimary():
	if primaryShowing == true:
		primaryShowing = false
		await primaryPanel.SetPosition(HideKey,true)

func ShowSecondary(obj:Node):
	secondaryPanel.Display(obj)
	if secondaryShowing == false:
		secondaryShowing = true
		await secondaryPanel.SetPosition(ShowKey,true)
	
func HideSecondary():
	if secondaryShowing == true:
		secondaryShowing = false
		await secondaryPanel.SetPosition(HideKey,true)

Create the Panels

Next lets work on making the controller node and the primary and secondary panels. In general the primary panel will be used to display info of the unit on the current tile, or the unit performing an action, while the secondary panel will be mostly for the target of actions, such as an attack or healing. In the end our panels should look something like this, and then once the code runs, there will be a random chance the panels will change to the enemy color. That will of course change once we add unit alliances in a later lesson.

Open the “Battle” scene. In the Scene Tree, lets add a child node to “Battle Controller” of type “Node”, name the node “Stat Panel Controller”. To that we’ll add a child node of type “Panel” and name it “Primary Stat Panel”. To that node, create the following children.

“Background Frame” of type “Nine Patch Rect”
“Avatar” of type “Texture Rect”
“Name Label” of type “Label”
“HP Label” of type “Label”
“MP Label” of type “Label”
“Level Label” of type “Label”

Select the “Primary Stat Panel” node, right click and select “Duplicate” or press “Ctrl + D”. Rename the duplicated node “Secondary Stat Panel”. All the children should still have the same names and we should have a scene tree that looks like this.

While I think we could save some time setting up the Primary Stat Panel before duplicating, I think it will be a bit clearer explaining things if we just tackle each piece on both sides at the same time. With that in mind, let’s start with the nodes for “Primary Stat Panel” and “Secondary Stat Panel”. The changes to these ones are pretty simple.

For the Primary Panel, go down to “Layout” and set the “Anchor Preset” to “Bottom Left” and under “Transform” set the “Size” to x:320, y:227, and “Position” to x:0, y:401. Toward the bottom under “Theme Overrides” set the “Theme” to “New StyleBoxEmpty” to get rid of the gray shading.

For the Secondary Panel, under “Layout” set “Anchor Preset to “Bottom Right” and set “Size” to x:320, y:227, and “Position” to X:832, y:401. Under “Theme Overrides” set the “Theme” to “New StyleBoxEmpty”

Next, lets work on “Background Frame” in each of the panels. We’ll start by giving them both the Texture, “Textures->UI->BlueAttackPanel.png”. Next under “Region Rect” set w:50px and h:116px. Under “Patch Margin” set Left:3px, Top:0px, Right:47px, Bottom:0px. Scroll down a bit and under the section “Layout” set the Layout Mode to “Anchors”, and for the “Anchor Preset” on the Primary panel side, choose “Bottom Left”, and on the Secondary Panel side, choose “Bottom Right”. The last stats that both share will be under the “Transform” section in Layout. set “Size” to x:320, y:100, and set “Position” to x:0, y:127.

For the “Background Frame” under the Secondary Panel, also set the “Scale” to x:-1, y:-1 and the “Pivot Offset” to x:160, y:50

For the “Avatar” we’ll set both to use the Texture, “Textures->UI->Avatar.png”

For the Primary side, check the box next to “Flip H”, set Layout Mode to “Anchors”, and set the Anchors Preset to “Bottom Left”. In the Transform section set “Size” x:154, y:227. “Position” we can leave at zero.

For the Secondary side, set Layout Mode to “Anchors”, set the Anchors Preset to “Bottom Right” and in the Transform section set the “Size” to x:154, y:227 and the “Position” to x:166, y:0

For these panels, all the Labels will use the same font, so lets get that set up first. In each of the Label objects(“Name Label”, “HP Label”, “MP Label” and “Level Label”) Under “Label Settings” choose “New Label Settings” and then click to expand it. Under Font, set the “Size” to 20px, and the color to white. Under outline set size to 5px and the color to R:0, G:0, B:0 and A:128 or use Hex:00000080

To make the task a little bit easier, if you right click on the Label Setting, you will get the option to copy/paste so you don’t have to type in each of these values for every Label.

Let’s start with the “Name Label”. For the Primary Panel side, under “Text” give a default value like “Arthur”. This will allow us to see placement while editing values, and if something goes wrong, we’ll be able to visually see that the value isn’t being updated. Set “Horizontal Alignment” to “Right” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and Anchor Preset to “Bottom Left”. Under Transform set “Size” to x:300, y:31 and “Position” to x:0, y:134

For the Secondary Panel side, give “Text” a value to use as default such as “Mordred”. Set “Horizontal Alignment” to “Left” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and Anchor Preset to “Bottom Right”. Under Transform set “Size” to x:280, y:31 and “Position” to x:40, y:134. The size is different for the Secondary Panel because when flipping the background, we are left with a larger line at the bottom of the panel instead of the top.

Next, the “HP Label”. On the Primary Panel side, under “Text” set a default value of something like “HP 83/90”. Set “Horizontal Alignment” to “Right” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Left”. Under Transform set “Size” to x:290, y:31 and “Position” to x:0, y:164

On the Secondary Panel Side, set default “Text” value as something like “HP 72/80”. Set “Horizontal Alignment” to “Left” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Right”. Under Transform set “Size” to x:290, y:31 and “Position” to x:30, y:164.

For the “MP Label”. On the Primary Panel side, under “Text” set a default value of something like “MP 20/25”. Set “Horizontal Alignment” to “Right” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Left”. Under Transform set “Size” to x:280, y:31 and “Position” to x:0, y:194

On the Secondary Panel side, set default “Text” value as something like “MP 23/31”. Set “Horizontal Alignment” to “Left” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Right”. Under Transform set “Size” to x:300, y:31 and “Position” to x:20, y:194.

“Level Label” is a little different. Because the text will be lined up on the opposite side of the panel, we’ll be switching some of the alignment values. On the Primary Panel side, under “Text” set a default value of something like “LV. 9”. Set “Horizontal Alignment” to “Left” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Left”. Under Transform set “Size” to x:112, y:31 and “Position” to x:20, y:194

On the Secondary Panel side, set default “Text” value as something like “LV. 5”. Set “Horizontal Alignment” to “Right” and “Vertical Alignment” to “Center”. Under Layout, set Layout Mode to “Anchors”, and set Anchor Preset to “Bottom Right”. Under Transform set “Size” to x:112, y:31 and “Position” to x:188, y:194.

Now that the panels have all been created, lets add the scripts we created earlier. To the node “Stat Panel Controller” add the script “StatPanelController.gd” and to “Primary Stat Panel” and “Secondary Stat Panel” add the script “StatPanel.gd”

In the inspector of “Stat Panel Controller” set the two export variables to the Primary and Secondary Panels.

Now for the panels themselves, we have a few more values to set up. The first several values we’ll set the same.

“Ally Background” texture to “Textures->UI->BlueAttackPanel.png”
“Enemy Background” texture to “Textures->UI->RedAttackPanel.png”

The next several set them to their corresponding nodes, just be sure to select the node under the correct panel. The last thing we have to set up is the Anchor List. We need 2 Panel Anchors, one for “Hide” and one for “Show”. These will be slightly different for each panel, so we’ll start with the “Primary Stat Panel”.

Anchor Name: Hide
My Anchor: Preset Bottom Right
Parent Anchor: Preset Bottom Left
Offset: x:0, y:-20
Duration: 0.5
Trans: Trans Quad
Anchor Ease: Ease Out

Anchor Name: Show
My Anchor: Preset Bottom Left
Parent Anchor: Preset Bottom Left
Offset: x:0, y-20
Duration: 0.5
Trans: Trans Quad
Anchor Ease: Ease Out

For the “Secondary Stat Panel”

Anchor Name: Hide
My Anchor: Preset Bottom Left
Parent Anchor: Preset Bottom Right
Offset: x:0, y:-20
Duration: 0.5
Trans: Trans Quad
Anchor Ease: Ease Out

Anchor Name: Show
My Anchor: Preset Bottom Right
Parent Anchor: Preset Bottom Right
Offset: x:0, y-20
Duration: 0.5
Trans: Trans Quad
Anchor Ease: Ease Out

Implementation

Now that we have everything the panels themselves need, we need to change several other scripts to add them to our battle.

Battle Controller

Let’s add a reference to our Stat Panel Controller in our Battle Controller. Open up the script “BattleController.gd” and with the other @export variables, add the line:

@export var statPanelController:StatPanelController

Save the script and go to the “Battle” scene and select our “Battle Controller” node, and assign the “Stat Panel Controller” to the new variable.

Battle State

In the base “BattleState.gd” class, lets add a wrapper for the statPanelController variable to allow subclasses to access it without calling to _owner.

var statPanelController:StatPanelController:
	get:
		return _owner.statPanelController

Next in the same class still, lets add a function to get the unit on any board position

func GetUnit(p:Vector2i):
	var t:Tile = _owner.board.GetTile(p)
	if t== null || t.content == null:
		return null
	return t.content

The last thing for this script are two functions to refresh the panels. Show or Hide it, depending on whether the tile has a unit on it.

func RefreshPrimaryStatPanel(p:Vector2i):
	var target:Unit = GetUnit(p)
	if target != null:
		statPanelController.ShowPrimary(target)
	else:
		statPanelController.HidePrimary()

func RefreshSecondaryStatPanel(p:Vector2i):
	var target:Unit = GetUnit(p)
	if target != null:
		statPanelController.ShowSecondary(target)
	else:
		statPanelController.HideSecondary()

Individual States

In any given turn, there are a lot of states that we may go through, all of which will have their own requirements of what panel needs to be open at any given time. Because of this, it can get tricky to keep track of which state should open or close a panel at any given time. This can lead to weird bugs where we may end up with things like a stat panel left open when it should have been closed.

To simplify things as much as possible, we’ll handle this by making every state responsible for itself. If the state shows the panel, it will also be in charge of hiding the panel.

CommandSelectionState, CategorySelectionState, and ActionSelectionState

In these three scripts, we’ll add these calls to show and hide the panels when the states enter and exit. Be sure that you add “await” on the call in the Exit() function, or it could cause the game to freeze when entering the new state.

func Enter():
	super()
	statPanelController.ShowPrimary(turn.actor)

func Exit():
	super()
	await statPanelController.HidePrimary()

Explore State

In ExploreState.gd we’ll add something similar to the previous states, but this time in the Enter() function, we’ll be calling RefreshPrimaryStatPanel() instead of ShowPrimary(). This is because when we are moving around the map, there may or may not be a unit on any given tile to show the info of. I also added a few commented out lines for the secondary panel here so that if you want to test the secondary panel, we can just swap out which lines are commented.

func Enter():
	super()
	RefreshPrimaryStatPanel(_owner.board.pos)
	#RefreshSecondaryStatPanel(_owner.board.pos)

func Exit():
	super()
	await statPanelController.HidePrimary()
	#await statPanelController.HideSecondary()

In addition to those two functions, we also need to add this to the end of the OnMove() function.

RefreshPrimaryStatPanel(_owner.board.pos)
#RefreshSecondaryStatPanel(_owner.board.pos)

Move Target State

In MoveTargetState.gd, we’ll be adding the same thing as the Explore State, but we already have an Enter() and Exit() function being used, so we just need to add the following line to the end of the Enter() and OnMove() functions.

RefreshPrimaryStatPanel(_owner.board.pos)

And to the end of the Exit() function, add the line

await statPanelController.HidePrimary()

Demo

Now we should have everything we need to show the panels. Run the Battle scene and if you cancel out of the Command Selection menu, or select Move, you should be able to explore around the map and see the stat panel show or hide when you go over units or empty tiles respectively.

We didn’t add anything this time around to use the Secondary Panel because we don’t have any actions or skills that target another unit, but the implementation is almost identical. To test the Secondary Panel, uncomment the lines in ExploreState.gd and comment out the lines for the Primary Panel above each of the three.

Once those changes are made, run the Battle scene and cancel out of the menu to enter the Explore State and test exploring the board. Once you have finished, be sure to return the comments back to their original state.

Summary

We can now see a number of a unit’s stats without going into the remote scene view and viewing them in the inspector. Slowly our UI is expanding and we are getting closer to a complete game.

As always, if you have any questions, feel free to ask in the comments, or check out the repository to compare your code.

4 thoughts on “Godot Tactics RPG – 12. Stat Panel

  1. As usual; great tutorial!

    – I think giving a link back to the reason why we use anchors could be good.
    – Is there a reason for not using type indication on function return? I’ve slowly been adding it to mine and I feel like it should help in the long term. What do you think?
    – I really dislike the fact of hiding the UI between each state. After giving it a quick go, it really quickly gets annoying ahah. What would be the best solution in your opinion? The two ideas are really just meh.
    1. Add a variable to the BaseAbilityMenuState: if it goes from a state with showPrimary to another state without, then hide the panel. But then you end up with a little something kinda “outside” the state.
    2. Add an event based service that listens to transitions and actually take care of the display/hiding of the UI. You can simply add a little bit of delay (100ms? Something almost imperceptible) to decide whether or not to show the UI.

    Or should I just change the StateMachine itself to check for it in the “ChangeState”? Kinda like listening to the transition?

    Thanks a lot for all your good content, keep it up!

    1. In hindsight it could have been beneficial to link to the older tutorials, and yes adding a type for the return would be useful, in both catching errors, and could gain a little performance from Godot. According to the documentation, dynamic typing does add some overhead, though I don’t know how much of an impact it has on return types.

      Yea if you don’t like how it works, certainly give it a shot changing it. I think the error that is most likely will be a panel left open when it should have been closed, so if we start with the assumption that a panel should close by default, that will minimize the risk somewhat. I think either solution has some potential.

      The first option I think you’ll want to go up the inheritance chain one more level to BattleState, and my initial thought would be to add something like a bool variable ‘hasStatPanel’ with a default false. That way if you check the new state and forgot to overload it, the worst that happens is that the panel closes before opening again, and you may need a second variable to store the flag of the next state. Then I’d set that variable just before calling changeState(), then finally when you call hide, you check that second variable.

      For the second option, I’d say instead of calling hide, you create something like queueHide(), with your delay and then in the new state, if it is going to show a panel, call cancelQueue() when you enter the state. This will again have the default option be to hide the panel.

      And the third option, changing the state machine. You’ll probably have to create a new state machine if we need it for anything else in the future if you do. I can’t remember if this tutorial uses it for the AI or not, so that’s something to consider. You’ll still probably need to set a flag like the first option. Otherwise this would work fairly similar to the first option, just a few different locations for your checks.

      Anyway, good luck, and glad you are enjoying the series.

      1. Alright, I went ahead and implemented the first solution! Works nicely 🙂

        But I spent a solid hour and half trying to understand what was happening ~ the abililty menu panel would appear and then disappear when changing state. Turns out, if you do something like:

        func Exit():
        super()
        if !nextStateHasStatPanel:
        await statPanelController.HidePrimary()

        You can get timing issue!
        Because in the parent function Exit, inside of BaseAbilityMenuState, we’re using await so creating a coroutine.
        And since you’re not necessarily awaiting for HidePrimary anymore with my code, it is possible to get out of your child class exit and enter the next state BEFORE the parent await is done, basically showing and hiding your menu.
        I fixed it by simply changing “super()” into “await super()” so it now properly waits for the action panel to be changed before moving on. Might introduce some delay, I’ll have to see but for now, that feels a bit better.

        Does it make sense? I’m not really sure of my understanding of Godot await implementation but having played with asynchronous code (angular mostly) in the past, it seems likely to be correct.

        Have a great day!

        1. Glad you got it to work. Yea, I’m not sure my understanding is the best either. Its been some of the trickiest bits to translate from Unity, especially going from yield to await. Oh, something you might consider, I didn’t have the panel showing up in the SelectUnitState, as that was only passing through briefly. You might want to add it visible there.

Leave a Reply

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