Godot Tactics RPG – 08. Ability Menu

7thSage again. This time we’ll be working to extend our UI a bit. We’ll be creating a menu to choose which action to take and add a rough implementation of a turn system to cycle through characters. We’ll also add the ability to cancel actions, and create an object pool to store our menu items.

Refactoring

Before getting started there are a couple things I need to touch on from previous lessons. The first is “StateMachine.gd”, we need to add two await commands. Where we call Exit() and Enter()

func ChangeState(newState: State) -> void:
	if _currentState == newState:
		return
	
	if _currentState:
		await _currentState.Exit()

	_currentState = newState
	
	if _currentState:
		await _currentState.Enter()

For the menu we’ll be creating shortly, we need to work with PanelContainer. These are similar to the Panel we used in the previous lesson, but handle resizing different. But because these are two separate node types, we can’t use the “LayoutAnchor.gd” script as we did before. To fix the issue, we’ll just extend a bit more generic so we don’t run into that problem.

extends Node
class_name LayoutAnchor

Object Pooling

Another common design pattern in games is Object Pooling. Creating and deleting large numbers of objects can be a fairly costly operation. To get around this, instead of creating a new object every time, we grab one that is already created out of the pool, a list of objects already created. When we are done then, instead of destroying it, we simply put it back in the pool. I haven’t benchmarked the code here with and without object pooling, so I don’t know if it is necessary, or even whether it is potentially harmful. However, it was in the original code so I wanted to preserve it, and more importantly it is something that can be used in other areas where we are pooling more than a couple objects. I should also note that the creator of Godot does not seem to believe that pooling is necessary as the engine is relatively efficient at creating and destroying objects, and while that may be generally accurate, it does seem overly optimistic.

If you’d like more information on the ideas behind the script, check out Jon’s original post where he creates the csharp script that was imported into the Unity version of the project. And if you’d like more detail about object pooling in general, there is a chapter on it in the book I mentioned before, Game Programming Patterns.

Before getting into the meat of the pooling, we’ll create a couple scripts to hold our data. The first in “Scripts->View Model Component” named “Poolable.gd”. This contains a string to identify which pool group the object is a part of. For instance we could have one group for bullets(arrows?), and another for menu items.

extends Node
class_name Poolable

var key: String
var isPooled: bool

The next script is in “Scripts->Model” named “PoolData.gd”. This holds the thing we want to pool, and the actual array of those objects that will get swapped in and out.

extends Node
class_name PoolData

var prefab:PackedScene
var maxCount: int
var pool:Array[Poolable]=[]

The main chunk of the pooling code is done in the next script we’re creating named “GameObjectPoolController.gd” in the folder “Scripts->Controller”. To go with this script let’s create a node in our Battle scene as a child under “Battle Controller” named “PoolController”. Attach the GameObjectPoolController.gd script to it. I made the name a bit more condensed without spaces to make it a bit more straight forward when we go to use it in a script. Right click on the node and select “% Access as Unique Name”. This will let us use this script as a global object anywhere in the scene by just typing “%PoolController”.

The original script was created as a Singleton, another Programming Pattern. There are two main components to what makes something a singleton. Global Access, and only allow a Single Instance to be created. Instead of using Unique Name, we could also make the object global in the project settings, but I don’t think we need access to this script outside of the current scene. To make our script single access we could mark things in our class as Static, but I think we should be fine just attaching the object to our node and calling it a day.

The start of the script is pretty simple. We’ll extend node and create a dictionary of the groups that we’ll pool. Each entry will then contain an array of the actual objects we’re pooling.

extends Node

var pools:Dictionary = {}

The first function we’ll create AddEntry() will set up our pool inside the dictionary. We’ll create the function Enqueue shortly. This is what we’ll use to add objects to the pool, or add them back to the pool, whichever the case may be.

func AddEntry(key:String, prefab: PackedScene, prepopulate: int ,maxCount: int)->bool:
	if pools.has(key):
		return false
	
	var data:PoolData = PoolData.new()
	data.prefab = prefab
	data.maxCount = maxCount
	pools[key] = data
	for i in prepopulate:
		Enqueue(CreateInstance(key,prefab))
	
	return true

The Enqueue() function mentioned earlier. Poolable objects contain a reference to which dictionary key they belong to so we can put them back in the pool. We use that here to access the correct group in pools[sender.key]. If we already have too many nodes in our pool, we discard the new one we are trying to add. Before adding the nodes themselves, we unparent them if they have a parent, and parent them to the pool object.

func Enqueue(sender:Poolable):
	if(sender == null || sender.isPooled || not pools.has(sender.key)):
		return
	
	var data:PoolData = pools[sender.key]
	if data.pool.size() >= data.maxCount:
		sender.free()
		return
	
	data.pool.push_back(sender)
	if(sender.get_parent()):
		sender.get_parent().remove_child(sender)
	self.add_child(sender)
	sender.isPooled = true
	sender.hide()

The counterpoint to that function is Dequeue(). We’ll use this to remove objects from the pool and add them to our scene. We use pop_front() to grab and remove the object off the front of the pool array.

func Dequeue(key:String)->Poolable:
	if not pools.has(key):
		return null

	var data:PoolData = pools[key]
	if data.pool.size() == 0:
		return CreateInstance(key, data.prefab)
	
	var obj:Poolable = data.pool.pop_front()
	obj.isPooled = false
	return obj

We’ll use the function CreateInstance() to actually instantiate our objects before we add them to the pool.

func CreateInstance(key:String, prefab:PackedScene)->Poolable:
	var instance = prefab.instantiate()
	instance.set_script(Poolable)
	instance.key = key
	return instance

Two last functions, ClearEntry() and SetMaxCount(), that we don’t use directly in this lesson, but in the name of making our script more general for the rest of the game, or other projects, whichever the case may be, we’ll add them. ClearEntry() will delete the specific pool if we are done with it, While SetMaxCount() will allow us to modify the max entries any given pool can have.

func ClearEntry(key:String):
	if not pools.has(key):
		return
	
	var data:PoolData = pools[key]
	while data.pool.size() > 0:
		var obj:Poolable = data.pool.pop_front()
		obj.free()
	pools.erase(key)

func SetMaxCount(key:String, maxCount:int):
	if not pools.has(key):
		return
	pools[key].maxCount = maxCount

Ability Menu Entry

We’ll be adding this script to our menu entries. It will keep track of whether the item is locked, or selected and change the bullet icon based on its selection status.

Create a new script in “Scripts->View Model Component” named “AbilityMenuEntry.gd”

We start out with the basic extends and setting its class name. Next is an enum that holds our possible states. We’re using this a little different this time. Instead of treating each enum value as a separate number, we’re breaking down the enum into its binary bits, treating each binary digit as a separate flag. This lets us have an enum that can hold multiple states at a single time. In this case for instance, it is possible to set the enum to be both Selected and Locked at the same time.

extends Node
class_name AbilityMenuEntry

enum States
{
	NONE = 0,
	SELECTED = 1 << 0,
	LOCKED = 1 << 1	
}

The bit shifting operators in this translate to all zeros for none, the 0th position set to 1 for Selected, and for Locked the second digit is set to 1.

Locked = 0000
Selected = 0001
Locked = 0010
Both Selected and Locked = 0011

Next is the export values that we will assign our objects and textures to.

@export var bullet:TextureRect
@export var label: Label
@export var normalSprite:Texture2D
@export var selectedSprite:Texture2D
@export var disabledSprite: Texture2D

While the next few bits look long, its really just a couple variables with getter and setter scripts inside. We start with a variable _state to hold the States enum. We won’t access this directly, but instead use another variable State with a get and set function. We simply return the value from _state. In the set function we set the new value to _state, and that is all for setting the variable. The rest of the set script sets the visual elements to match.

var _state:States
var State:States :
	get:
		return _state
		
	set(value):
		_state = value
		
		var font_color:String = "theme_override_colors/font_color"
		var font_outline_color:String = "theme_override_colors/font_outline_color"
		
		if IsLocked:
			bullet.texture = disabledSprite
			label.set(font_color, Color.SLATE_GRAY)
			label.set(font_outline_color, Color(0.078431, 0.141176, 0.172549)) #rgb:20, 36, 44
		elif IsSelected:
			bullet.texture = selectedSprite
			label.set(font_color, Color(0.976470, 0.823529, 0.462745)) #rgb:249, 210, 118
			label.set(font_outline_color, Color(1.0, 0.627450, 0.282352)) #rgb:255, 160, 72
		else:
			bullet.texture = normalSprite
			label.set(font_color, Color.WHITE)
			label.set(font_outline_color, Color(0.078431, 0.141176, 0.172549)) #rgb:20, 36, 44

For the variables IsLocked and IsSelected, we use bitwise operators to isolate the relevent binary bits and return or set only those pieces from the _state variable.

var IsLocked:bool :
	get:
		return (State & States.LOCKED) != States.NONE
	set(value): 	
		if value:
			State |= States.LOCKED
		else:
			State &= ~States.LOCKED
		
var IsSelected:bool :
	get:
		return (State & States.SELECTED) != States.NONE
	set(value):
		if value:
			State |= States.SELECTED
		else:
			State &= ~States.SELECTED

The rest of the script is pretty straight forward. We’ll use title to get and set the value on our entry label, and Reset will return our enum to its default.

var Title:String :
	get:
		return label.text
	set(value):
		label.text = value
		
func Reset():
	State = States.NONE

Ability Menu Panel Controller

Next we’ll set up a controller to manage our menus. Here we’ll show and hide our menu and control pulling objects in and out of the object pool. Let’s create a new script named “AbilityMenuPanelController.gd” in the folder “Scripts->Controller”. To go along with it create a new Node as a child of “Battle Controller” named “Ability Menu Controller”. Attach the script we just created to it.

Now, in the script we just created, start by leaving the default extends and give it a class_name.

extends Node
class_name AbilityMenuPanelController

Next we’ll create several const variables such as the key that we want our menu to use within the object pool.

const ShowKey:String = "Show"
const HideKey:String = "Hide"
const EntryPoolKey:String = "AbilityMenuPanel.Entry"
const MenuCount:int = 4

Next we’ll add some @export values that will hold the prefab of our menu entry, a link to the Label node that holds the menu’s title, the Panel that contains our menu(which we’ll be creating the script for shortly), and also a ref to the VBoxContainer that will be the parent of our entries. We’ll also create an array of our menu entries and an int that holds the position of the current selected item.

@export var entryPrefab:PackedScene
@export var titleLabel:Label
@export var panel:AbilityMenuPanel
@export var entryVbox:VBoxContainer
var menuEntries: Array[AbilityMenuEntry] = []
var selection:int

In the _ready() function we’ll create a pool that will hold our menu items. We’ll use the unique name we created earlier, %PoolController. We’ll also hide and disable our node to start. While we’re at it, we’ll create functions to enable and disable our panel like we’ve created in the past, which probably means eventually we should think of a place to refactor those functions so we aren’t constantly recreating them.

func _ready():
	%PoolController.AddEntry(EntryPoolKey, entryPrefab, MenuCount, 2147483647)
	panel.SetPosition(HideKey, false)
	_DisableNode(panel)

func _DisableNode(node:Node) -> void:
	node.process_mode = Node.PROCESS_MODE_DISABLED
	node.hide()

func _EnableNode(node:Node) -> void:
	node.process_mode = Node.PROCESS_MODE_INHERIT
	node.show()

Next are the functions that will show and hide our menu. In Show() we start by reenabling our menu and clearing any previous values. Next we set the title, but instead of sending the string directly, we’ll pass it through the translation engine with tr(). If we don’t have a key in our translation files, it will use the key itself. I won’t add translations for these values in the tutorial, but I wanted to contiue to point out where we’d place those values.

Next we loop through all the items in our menu. We’ll start by getting an object from the pool with Dequeue(). Once we have our object we’ll set the name of the menu entry, sending it through translation like we did with the title. Next we’ll take the object we got from the pool and add it to our menuEntries array to keep track of it.

Once we’re done with all the menu entries, we’ll set our selection position to the top of the menu and move our menu to its show position and await for the animation to finish before continuing.

Hide() is a bit simpler. We’ll move the panel off screen, use Clear() to send our entries back to the pool, but before we disable our panel, we shrink the panel down so we don’t have to worry about blank slots being left after loading larger menus. Then we disable our panel until we need it next time.

func Show(title:String, options:Array[String]):
	_EnableNode(panel)
	Clear()
	titleLabel.text = tr(title)
	for option in options:
		var entry:AbilityMenuEntry = Dequeue()
		entry.Title = tr(option)
		menuEntries.append(entry)
	SetSelection(0)
	await panel.SetPosition(ShowKey, true)

func Hide():
	await panel.SetPosition(HideKey, true)
	Clear()
	
	#force panel to shink before fitting to the correct size
	panel.size = Vector2(0,0)
	panel.SetPosition(HideKey, false)
	
	_DisableNode(panel)

There are certain circumstances where we may want to lock entries, such as if we’ve already completed said action this turn, or some other feature locks it. We’ll use this to set the entry to locked.

func SetLocked(index:int, value:bool):
	if( index < 0 || index >= menuEntries.size()):
		return
	
	menuEntries[index].IsLocked = value
	if (value && selection == index):
		Next()

Next() and Previous() we will call from the OnMove() function of our state machine. When we get to that point, we won’t be adjusting these movements for camera angle. If SetSelection() returns false, for instance when the entry is locked, it goes on to the next result, skipping any locked entries.

func Next():
	if menuEntries.size() == 0:
		return
	for i in range(selection + 1, menuEntries.size() +2):
		var index:int = i % menuEntries.size()
		if SetSelection(index):
			break

func Previous():
	if menuEntries.size() == 0:
		return
	for i in range(selection - 1 + menuEntries.size(), selection, -1):
		var index:int = i % menuEntries.size()
		if SetSelection(index):
			break

We’ll call Dequeue() and Enqueue() to add and remove our entries to the object pool.

func Dequeue()->AbilityMenuEntry:
	var p:Poolable = %PoolController.Dequeue(EntryPoolKey)
	var entry:AbilityMenuEntry = p.get_node("Entry")
	
	if(p.get_parent()):
		p.get_parent().remove_child(p)
	entryVbox.add_child(p)
	_EnableNode(p)
	entry.Reset()
	return entry

func Enqueue(entry:AbilityMenuEntry):
	var p:Poolable = entry.get_parent()
	%PoolController.Enqueue(p)

The last two functions for this script. Clear() returns objects to the pool and cleans up the array, while SetSelection() has some checks if an option is locked or out of bounds and returns false, otherwise it sets the selection index and returs true.

func Clear():
	for i in range(menuEntries.size()-1, -1, -1):
		Enqueue(menuEntries[i])
	menuEntries.clear()

func SetSelection(value:int)->bool:
	if menuEntries[value].IsLocked:
		return false
	
	#Deselect the previously selected entry
	if (selection >= 0 && selection < menuEntries.size()):
		menuEntries[selection].IsSelected = false
	
	selection = value
	
	#Select the new entry
	if(selection >= 0 && selection < menuEntries.size()):
		menuEntries[selection].IsSelected = true
	
	return true

Ability Menu Panel

Now lets create the actual panel that will hold our menu. Start by creating a new node under “Ability Menu Panel Controller” named “Ability Menu Panel” of the type “Panel Container”, careful to choose Panel Container and not plain Panel.

Next under “Scripts->View Model Component” create a script named “AbilityMenuPanel.gd” and attach it to our node. The script is pretty simple, we extend LayoutAnchor, and just like in our conversation panel, we create a list of anchor points and we add a couple functions to set and get our anchor points by their string names.

extends LayoutAnchor
class_name AbilityMenuPanel

@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

Next in the inspector, add 2 elements to our Anchor List array. “Hide” and “Show” with the values used in the picture below.

Turn

Next up we’ll set up a script to keep track of player turn. The purpose of the script here is to keep track of what actions a unit has taken on their turn, and to allow us to undo their movement, assuming we haven’t attacked already.

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

There are two functions in this script. The first one, Change(), is called when a units turn first begins and will store the initial location and other variables for the unit so that if we decide to undo our move, we can go back to them. The second function, UndoMove(), takes those variables and positions the character back to that location and direction.

extends Node
class_name Turn

var actor:Unit
var hasUnitMoved:bool
var hasUnitActed:bool
var lockMove:bool
var startTile:Tile
var startDir: Directions.Dirs

func Change(current:Unit):
	actor = current
	hasUnitMoved = false
	hasUnitActed = false
	lockMove = false
	startTile = actor.tile
	startDir = actor.dir
	
func UndoMove():
	hasUnitMoved = false
	actor.Place(startTile)
	actor.dir = startDir
	actor.Match()

Battle Controller

We’ll need to add a couple lines to “BattleController.gd”. The first is a reference to the Ability Menu Controller, which once we’re done with the edits, in the inspector, set the variable to point to the Ability Menu Controller node. The next two variables are Turn, that will hold our current turn, and an array of the units on the field so that we can have a list of them to cycle through.

@export var abilityMenuPanelController:AbilityMenuPanelController
var turn:Turn = Turn.new()
var units:Array[Unit] = []

While we’re there, let’s delete the line “var currentUnit:Unit”. We’ll now get the unit from Turn instead. Once deleted we’ll have to update the broken lines in “MoveSequenceState.gd” and “MoveTargetState.gd”, but first we’ll add some convenience shortcuts to Battle State.

Battle State

We’ll add three variables to “BattleState.gd” which will have getters that call their respective variables in _owner.

var abilityMenuPanelController:AbilityMenuPanelController:
	get:
		return _owner.abilityMenuPanelController

var turn:Turn: 
	get:
		return _owner.turn

var units:Array[Unit]:
	get:
		return _owner.units

Move Sequence State

In the Sequence() function we need to change the two lines to the following. Instead of _owner.currentUnit, we’ll use turn.Actor (which, without the changes in Battle State, would be the same as calling _owner.turn.Actor)

var m:Movement = turn.actor.get_node("Movement")
_owner.cameraController.setFollow(turn.actor)

Move Target State

In Enter() we’ll change the following line.

var mover:Movement = turn.actor.get_node("Movement")

Init Battle State

Now that we have an array to keep track of the units on the board, we need to actually add those units to the array. In “InitBattleState.gd” in the function SpawnTestUnits(), place this inside the for loop at the end.

units.append(unit)

Select Unit State

For SelectUnitState.gd, we’re going to replace the current contents with the following script. The original version was a placeholder that let us select any unit without regards to order. Now we’ll be cycling through the units selecting each one in turn. Later we’ll expand this further to take speed and such into account, but for now it will at least bring us closer to the proper unit order of a full battle. As selecting the next unit will be automatic, we won’t need to worry about the camera movement.

We are linking to a new state in this script. Later on in this lesson once we have created the new State types, we’ll come back and link the variable in the inspector.

extends BattleState

@export var commandSelectionState: State
var index:int = -1

func Enter():
	super()
	ChangeCurrentUnit()

func ChangeCurrentUnit():
	index = (index + 1) % units.size()
	turn.Change(units[index])
	_owner.stateMachine.ChangeState(commandSelectionState)

Explore State

Now that units are selected automatically, we no longer have the ability to move the cursor freely around the board to get a lay of the land. To remedy this, we’ll create a new state that we can temporarily go into by canceling from the command menu.

Create a new script in “Scripts->Controller->Battle States” named “ExploreState.gd”

Nothing new in this script, we’ve just pulled in code from things like the old version of Select Unit State.

extends BattleState

@export var commandSelectionState: State

func OnMove(e:Vector2i):
	var rotatedPoint = _owner.cameraController.AdjustedMovement(e)
	SelectTile(rotatedPoint + _owner.board.pos)

func OnFire(e:int):
	print("Fire: " + str(e))
	if e == 0:
		_owner.stateMachine.ChangeState(commandSelectionState)

func Zoom(scroll: int):
	_owner.cameraController.Zoom(scroll)

func Orbit(direction: Vector2):
	_owner.cameraController.Orbit(direction)

Base Ability Menu State

Next up we’ll be creating several menu states that all share a fair amount of functionality. So we’ll create a base class for them to inherit from.

In “Scripts->Controller->Battle States” create a new script named “BaseAbilityMenuState.gd”

In Enter() and Exit() we show and hide our menu. In OnFire() we separate our fire inputs to either cancel or confirm instead of just setting all to confirm as we did previously. In OnMove() we move the current menu position depending on whether there is a positive or negative value on the x or y axis. Because these are in screen space and not world space, we don’t adjust the movement based on the camera. The last three functions we’ll override in the classes that inherit this script.

extends BattleState
class_name BaseAbilityMenuState

var menuTitle:String
var menuOptions:Array[String] = []

func Enter():
	super()
	SelectTile(turn.actor.tile.pos)
	await LoadMenu()

func Exit():
	super()
	await abilityMenuPanelController.Hide()

func OnFire(e:int):
	if(e == 0):
		Confirm()
	else:
		Cancel()

func OnMove(e:Vector2i):
	if(e.x > 0 || e.y > 0):
		abilityMenuPanelController.Next()
	else:
		abilityMenuPanelController.Previous()

func LoadMenu():
	pass

func Confirm():
	pass

func Cancel():
	pass

Command Selection State

The first menu state that we’ll be creating, and the first one we will see in our turn is the Command Selection State. Create a new script in “Scripts->Controller->Battle States” named “CommandSelectionState.gd”. This menu will give us the menu options of things like Move, make an Action, or Wait. While wait will end the turn, the others will return us to this menu once they are done, but with their respective choices locked.

We’ll start with the setup of the state, and then all that’s left for this state is to implement the three functions we left blank in the base.

extends BaseAbilityMenuState

@export var moveTargetState: State
@export var categorySelectionState: State
@export var selectUnitState: State
@export var exploreState: State

The top level of the menu will always have the same options, so we’ll list them out here. Depending on what options have already been used, we’ll lock some of the choices.

func LoadMenu():
	if(menuOptions.size() == 0):
		menuTitle = "Commands"
		menuOptions.append("Move")
		menuOptions.append("Action")
		menuOptions.append("Wait")
	
	
	abilityMenuPanelController.Show(menuTitle, menuOptions)
	abilityMenuPanelController.SetLocked(0, turn.hasUnitMoved)
	abilityMenuPanelController.SetLocked(1, turn.hasUnitActed)

When we press a button to confirm, we change state based on which menu item is selected.

func Confirm():
	match(abilityMenuPanelController.selection):
		0:
			_owner.stateMachine.ChangeState(moveTargetState)
		1:
			_owner.stateMachine.ChangeState(categorySelectionState)
		2:
			_owner.stateMachine.ChangeState(selectUnitState)

If instead of pressing confirm, we press cancel instead, one of two things happen. If we’ve already moved and it is still undoable( i.e. we haven’t attacked), our move will undo. Otherwise we will switch to the Explore State.

func Cancel():
	if(turn.hasUnitMoved && !turn.lockMove):
		turn.UndoMove()
		abilityMenuPanelController.SetLocked(0, false)
		SelectTile(turn.actor.tile.pos)
	else:
		_owner.stateMachine.ChangeState(exploreState)

Category Selection State

Create a script named “CategorySelectionState.gd” in “Scripts->Controller->Battle States”.

This menu becomes active when Action from the top menu is selected. While Attack is a complete action, we can also choose options such as Black Magic that will open another section of menu choices to choose from. While Attack would be a standard entry that we’ll always have, later we’ll want to replace the hard coded spells with choices gotten from the unit, such as their job.

extends BaseAbilityMenuState
@export var commandSelectionState:State
@export var actionSelectionState:State


func LoadMenu():
	if(menuOptions.size() == 0):
		menuTitle = "Action"
		menuOptions.append("Attack")
		menuOptions.append("White Magic")
		menuOptions.append("Black Magic")

	abilityMenuPanelController.Show(menuTitle,menuOptions)
	
func Confirm():
	match( abilityMenuPanelController.selection):
		0:
			Attack()
		1:
			SetCategory(0)
		2:
			SetCategory(1)

func Cancel():
	_owner.stateMachine.ChangeState(commandSelectionState)

func Attack():
	turn.hasUnitActed = true
	if(turn.hasUnitMoved):
		turn.lockMove = true
	_owner.stateMachine.ChangeState(commandSelectionState)

func SetCategory(index:int):
	actionSelectionState.category = index
	_owner.stateMachine.ChangeState(actionSelectionState)

In LoadMenu() we aren’t locking any menu entries this time. In the future we may add that functionality so specific attacks or categories can be locked by things like status conditions, such as Mute or Don’t Act.

When the user selects attack, we set hasUnitActed to true. This will lock movement if the character has already moved. We don’t do this in SetCategory() because we haven’t selected the actual action yet, that would happen in the next menu where we select the actual spell to use.

Cancel will bring us back to the previous menu, Command Selection State.

Action Selection State

This is the last menu for now. As the last menu, the choices will be loaded dynamically later on, but for now depending on which category is selected in the previous menu, we will load a set of skills that matches.

Create a script named “ActionSelectionState.gd” in “Scripts->Controller->Battle States”.

All the functions here should be pretty similar to what we have previous. The biggest change is that we moved adding the menu items to the menu to its own function SetOptions(). Because we have two possible menus that we could be loading, it makes sense to break it out. I’ve used two different array lengths so we can see some different size menus.

extends BaseAbilityMenuState
@export var commandSelectionState:State
@export var categorySelectionState:State

static var category:int
var whiteMagicOptions:Array[String] = ["Cure", "Raise"]
var blackMagicOptions:Array[String] = ["Fire", "Ice", "Lightning", "Poison"]

func LoadMenu():
	if(category == 0):
		menuTitle = "White Magic"
		SetOptions(whiteMagicOptions)
	else:
		menuTitle = "BlackMagic"
		SetOptions(blackMagicOptions)
	abilityMenuPanelController.Show(menuTitle,menuOptions)

func Confirm():
	turn.hasUnitActed = true
	if(turn.hasUnitMoved):
		turn.lockMove = true
	_owner.stateMachine.ChangeState(commandSelectionState)

func Cancel():
	_owner.stateMachine.ChangeState(categorySelectionState)

func SetOptions(options:Array[String]):
	menuOptions.clear()
	for entry in options:
		menuOptions.append(entry)

Move Target State

Just a couple more small tweaks to the Move Target State, this time to let us cancel out of movement. The first we need to add an additional export variable for the menu we’ll be returning to.

@export var commandSelectionState:State

And we need to add the cancel option inside our OnFire() function.

func OnFire(e:int):
	if e == 0:
		if tiles.has(_owner.currentTile):
			_owner.stateMachine.ChangeState(MoveSequenceState)
	else:
		_owner.stateMachine.ChangeState(commandSelectionState)

Move Sequence State

Now that we have the rest of our States for this lesson, there is a little more to tweak in the Move Sequence State as well. We need to change the State we are changing to. Swap the references from SelectUnitState to CommandSelectionState. We also need to set the variable for hasUnitMoved to true just before changing states.

@export var commandSelectionState:State
turn.hasUnitMoved = true
_owner.stateMachine.ChangeState(commandSelectionState)

State Setup

Now that we have all of our States coded, lets create the nodes for all of them and wire them up. In our scene under the “State Machine” node, lets create four more nodes, “Action Selection State”, “Command Selection State”, “Category Selection State”, and “Explore State”. To these we’ll add each of their respective scripts.

Be sure to go to the inspector of each one to set up the States they will transition to. In addition we made tweaks to a few other states so be sure to touch on those as well. “Select Unit State”, “Move Target State”, and “Move Sequence State” will all need to be updated.

Ability Menu

Now for the scene setup. We’ll be going for something that looks like this.

We’ll be using the Panel Container itself to hold our background. In the previous lesson we used Panel to hold the dialog window because the portrait extended beyond the background, and it was the best way I could figure out how to make it work. Here though, the menu is contained completely inside our menu window, and more important here, Panel Container scales to fit the number of menu items much easier.

We also won’t be using a nine patch rect this time, instead we’ll use a theme override on the PanelContainer. Select the node “Ability Menu Panel” and in the inspector find the “Theme Overrides” section. Next to the word Panel, we’re going to choose “New StyleBoxTexture”.

Expand StyleBoxTexture and we have a few settings to edit. First, under texture click “Load” and find the texture “Textures->UI->AbilityMenu.png”. Like the nine patch rect, we need to set the area for the panel to stretch. We’ll do this with the values in the Texture Margins, and for some padding of where to place objects on top of the menu, we’ll use Content Margins. The values for both can be found in the following picture.

Before we move on, under Layout set the “Anchor Preset” to “Bottom Right”.

Next lets start laying out the things inside the menu. Start by creating a child node under “Ability Menu Panel” of the type “VBoxContainer” named “Panel VBox”. This will line up all the children of this object vertically in a column. Under “Theme Overrides->Constants”, set Separation: 3

The first child we’ll create will be for the title. The title label will be included in all the menus so we will build it into the menu, but the entries will be loaded in dynamically and pulled in through our object pooler. Although we’ll create the first entry directly in the panel, and save/remove it out as a prefab to use with the pooler.

Under “Panel VBox” create a child of type “Margin Container” named “Title Margin”. We want our Title to be shifted over to the side of the dot in the picture and the Margin Container will let us adjust that. In the inspector under “Theme Overrides->Constants”, set Margin Left: 20 and Margin Bottom: 11

As a child to “Title Margin” create a node of type “Label” named “Title Text”. This will hold the title of our current menu or submenu. In the field Text, give it a default value like “Menu Title” so we can see what our menu is looking like as we build it. We override the text in the code we created earlier.

With the Title done, its time to focus on the entries. As a child of “Panel VBox”, we’re going to create another VBoxContainer name “Entry VBox”. Creating a second VBox like this allows us to adjust the spacing separate from the Title of the menu.

Under Theme Overrides, this time all we need to set is Separation: 5

Ability Menu Entry

Next let’s create our actual entry. Once we’re done we’ll save it out as a prefab to load in dynamically, and before that if you’d like to see what the menu will look like, you can duplicate the entries, just be sure to delete the duplicates before we move on.

Create a child of “Entry VBox” named “Ability Menu Entry” of type “PanelContainer”. To this attach the script “Poolable.gd”. Under Theme Overrides set the Panel style to “New StyleBoxEmpty”

As a child of “Ability Menu Entry” create another node of type “PanelContainer” named “Entry”. To this attach our script “AbilityMenuEntry.gd”. We’ll come back and add the fields in the inspector after we’ve created the rest of the children. Just like the previous node, set the Theme Overrides style to “New StyleBoxEmpty”.

Under “Entry” create a child of type “HBoxContainer” named “HBox”. Under that we’ll create two children. A TextureRect named “Bullet Rect” and a Label named “Entry Label”

For Bullet Rect, we’ll set the texture to “Textures->UI->MenuBullet.png”, and just below that, set “Expand Mode” to “Fit Width Proportional”.

Entry Label we have a couple more settings. We’ll start by giving the Text a default value, “Ability Entry”. For Theme Overrides, set the Font Outline Color: #4c4c4c, Outline Size:6, Font Size: 18

With that we have everything we need to finish setting up our menu and our scene tree for the menu should look like this.

Right click on “Ability Menu Entry” and select “Save Branch as Scene”. Save it in the folder “Prefabs” with the name “Ability Menu Entry.tscn”.

Open the prefab we just created. On the Entry node in the inspector. Link “Bullet Rect” to Bullet, and “Entry Label” to Label. Then for the sprites load their respective textures. I ran into an issue here where if I linked the variables Bullet and Label before creating the prefab, I had to redo the links. I don’t know if this has changed in later versions, or if it is just the version I’m using.

Normal Sprite: Textures->UI->MenuBullet.png
Selected Sprite: Textures->UI->MenuBulletSelected.png
Disabled Sprite: Textures->UI->MenuBulletLocked.png

Once that is done, we can delete “Ability Menu Entry” from our scene. We’ll load them in dynamically once the game starts.

Scene Setup

The last thing to do is to set the values in the inspector of “Ability Menu Controller”. Assign the prefab we just created to “Entry Prefab” and the corresponding nodes to each of the other three values.

And with that, we’re done. We should have everything all up and running now. We changed and added a lot of @export variables this time around, so if things aren’t working, go through the nodes and make sure all of them are assigned and linked to the correct nodes.

Summary

This was another big lesson, and doesn’t help that we needed to convert the object pooling script as well. On the bright side though, we can use that object pooler not just for this script, but anywhere in the future that we might want it. Our game is starting to look a bit more complete too, where we can actually select actions in a menu, although there is still a lot to do to get all those actions working.

As always, if you’re having trouble with the lesson, feel free to ask in the comments or check the repository.

2 thoughts on “Godot Tactics RPG – 08. Ability Menu

  1. Thanks for doing this, I’m a Godot newbie and this is exactly the type of game I want to make, looking forward to going through this series from the beginning!

  2. Yea, there isn’t a lot of Tactics tutorials out there, even for Unity, let alone Godot. I’d say Unity only has two good ones, the one here, and the paid course by Code Monkey. That’s why I wanted to bring this over to Godot. The next couple parts are in the wings as well, I just need to finish the script now that the hard part is done.

Leave a Reply

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