Let’s now create the action menu. It will list action buttons and allow the player to select one to use.
We’re going to split it into two nested scenes: the menu and the list. Doing so will allow you to change how the menu works in the future if you so desire. For example, while we will have only one list in this series, you may want to have sub-menus or sub-lists as your game and your available actions grow.
Our user interface is going to encapsulate and display information about the player’s battlers themselves. Over the next few lessons, we’re going to need that information, mainly:
Assuming every battler is a unique character, we’ll also use the display name to differentiate them and map a playable character to its HUD without storing a reference to the battlers in the user interface.
Our battler nodes have a name and some sprite attached to them, but we can’t use that directly in the user interface: the convention is to keep node names in PascalCase, and we may want a stylized or simple icon for the UI.
Long story short, we’re going to add a new resource to store this data.
Create a new GDScript file named
# Stores the properties required by UI widgets that represent this battler. class_name BattlerUIData extends Resource # The battler's name to display, for example, in the HUD or in a menu. export var display_name := "" # An icon representing the battler. We'll use it in the turn bar in lesson 4. export var texture: Texture
Then, let’s add a new property on the
Battler class to store the UI data. Open
export var ui_data: Resource
You’ll want to create a
BattlerUIData resource for each battler in their respective scenes.
For example, here’s the resource for my Bear battler.
battler.ui_data starting in this lesson, in a signal connection, and use it again later in this series.
The action list displays the available actions as a column of buttons.
On top of that, it has a selection arrow to give the user that traditional JRPG feel selecting options. But under the hood, we will rely on Godot’s built-in buttons and focus system to detect navigation and key presses.
Let’s create the selection arrow first, as the action list depends on it.
Create a new scene with a Position2D node named UIMenuSelectArrow as its root, a Sprite, an AnimationPlayer, and a Tween. Save it as
We’ll use the tween node to animate the arrow’s position. Simultaneously, the animation player will make the sprite wiggle, as you might’ve seen in games like older Final Fantasy titles. This way, both animations can play at the same time.
In the FileSystem dock, find
menu_selection_arrow.png and assign it to the sprite’s Texture property.
Place the sprite and its pivot so with a position of (0, 0), the arrow is to the left of the origin. To change the pivot’s position, select the sprite, place your mouse cursor where you want the pivot to be located, and press v.
With the AnimationPlayer, animate the sprite’s position going back and forth. To do so, you need two keys and to toggle the animation’s looping option. Also, set the animation to Autoplay on Load. In the image below, I’ve highlighted the icons corresponding to the two options.
On the first keyframe, I’ve pulled the easing curve to the left in the Inspector to give the motion some bounciness.
Attach a script to the UIMenuSelectArrow with the following code.
# Arrow to select actions in a [UIActionList]. extends Position2D onready var _tween: Tween = $Tween # The arrow needs to move indepedently from its parent. func _init() -> void: set_as_toplevel(true) # The UIActionList can use this function to move the arrow. func move_to(target: Vector2) -> void: # If it's already moving, we stop and re-create the tween. if _tween.is_active(): _tween.stop(self, "position") # To move the arrow, we tween its position, which is global, for 0.1 seconds. # This short duration makes the menu feel responsive. _tween.interpolate_property( self, "position", position, target, 0.1, Tween.TRANS_CUBIC, Tween.EASE_OUT ) _tween.start()
Next up is the action list scene. Create a new scene with a VBoxContainer named UIActionList as its root. Add an instance of the UISelectBattlerArrow scene as its child and save the scene. Attach a script to the root node.
The list will work with instances of the UIActionButton scene we coded in the previous lesson.
The list’s script will move the UIMenuSelectArrow to the different buttons based on the player’s input. Add the following code to the UIActionList’s script.
# List of UIActionButton the player can press to select an action. extends VBoxContainer # Emitted when the player presses an action button. signal action_selected(action) # We instantiate an action button for each action on the battler. See the setup() function below. # The file UIActionButton.tscn must be in the same directory for this to work. const UIActionButton: PackedScene = preload("UIActionButton.tscn") # Toggles all children buttons disabled. # You can use this to implement nested action lists, freezing this one while the user browses another. var is_disabled = false setget set_is_disabled # Among the node's children, there's the `UIMenuSelectArrow`, which isn't a button. # We use this array to access and process the buttons efficiently. var buttons :=  onready var _select_arrow := $UIMenuSelectArrow # To call from a parent node. Creates action buttons based on the battler's actions. func setup(battler: Battler) -> void: # Below, action is of type ActionData. for action in battler.actions: # This is why this node takes the battler as an argument: we use it to check if the battler # can use the action when creating the menu. var can_use_action: bool = battler.stats.energy >= action.energy_cost # Instantiates a button and calls its `setup()` function. var action_button = UIActionButton.instance() add_child(action_button) action_button.setup(action, can_use_action) # Here, we start using binds with the signal callbacks. For each button, # we bind the current `action` to its "pressed" signal. action_button.connect("pressed", self, "_on_UIActionButton_button_pressed", [action]) # We rely on the focus system of Godot's UI framework to know when the player # navigates between buttons. # We bind the button to retrieve its position from the callback function and move the arrow to it. action_button.connect( "focus_entered", self, "_on_UIActionButton_focus_entered", # Notice we also bind the battler's name and the action's energy cost. # We will need them later to map a battler node to its corresponding Heads-Up Display # without storing a reference to the battler in the UI. # I'm adding them now so we don't have to edit this bit later on. [action_button, battler.ui_data.display_name, action.energy_cost] ) buttons.append(action_button) # This centers the arrow vertically with the first button and places it on its left. _select_arrow.position = ( buttons.rect_global_position + Vector2(0.0, buttons.rect_size.y / 2.0) ) # The list itself being a VBoxContainer, it can't grab focus. # Instead of focusing the list itself, we want its first button to grab focus. func focus() -> void: buttons.grab_focus() # Disabling the list disables all buttons. func set_is_disabled(value: bool) -> void: is_disabled = value for button in buttons: button.disabled = is_disabled # When a button was pressed, it means the player selected an action, which we emit with the # "action_selected" signal. func _on_UIActionButton_button_pressed(action: ActionData) -> void: set_is_disabled(true) emit_signal("action_selected", action) # When a new button gets focus, we move the arrow to it to make it look like you control the menu # with the arrow. But the arrow's just a visual cue. # As mentioned above, we have extra values we'll use later, when displaying the character's stats. func _on_UIActionButton_focus_entered(button: TextureButton, battler_display_name: String, energy_cost: int) -> void: _select_arrow.move_to(button.rect_global_position + Vector2(0.0, button.rect_size.y / 2.0))
The script’s central part is once again the
function that populates the list with action buttons. Notice how the
arrow only moves around but isn’t the backbone of our menu. Instead, we
rely on some Control nodes’ ability to grab and release focus to know
when the player selected a different button.
Let’s create yet another scene for our action menu. In this series, it has limited functionality: it opens, closes, and forwards the UIActionList’s signal. Now, the idea is to have a flexible setup that allows you to modify the menu later. In the RPG combat prototype, there aren’t enough actions to justify supporting nested lists or having multiple action lists, but you might want to add this feature to your game. And to do so, you want the menu and the lists to be separate entities.
Create a scene with a single Control node named UIActionMenu and attach a script to it with the following code.
# Menu displaying lists of actions the player can select. class_name UIActionMenu extends Control # Emitted when the player selected an action. signal action_selected # We preload our UIActionList scene to instantiate it from the code. # The file UIActionList.tscn must be in the same directory for this to work. const UIActionList := preload("UIActionList.tscn") func _ready() -> void: hide() # This function is a bit like the previous nodes' `setup()`, but I decided to call it open # instead because it feels more natural for a menu. Also, it toggles the node's visibility on. func open(battler: Battler) -> void: var list = UIActionList.instance() add_child(list) # We listen to the UIActionList's action_selected signal to automatically close the menu and # forward the signal when the player selects an action. list.connect("action_selected", self, "_on_UIActionsList_action_selected") list.setup(battler) show() # Calling the list's `focus()` method allows the first button to grab focus. list.focus() # We free the menu upon closing it. func close() -> void: hide() queue_free() func _on_UIActionsList_action_selected(action: ActionData) -> void: emit_signal("action_selected", action) close()
And that’s our menu. As with many interfaces and objects that need some initialization, I recommend creating a new instance of the menu when the player wants to open it and to destroy the nodes when closing it. With a fresh interface instance, you avoid bugs linked to changes in the nodes’ state.
Unless your interface is complex, doing this should not impact the game’s performance at all.
In our final demo, we set the action menu to a specific position in the game view, one that works roughly regardless of the battlers’ positions.
To do so, head to the CombatDemo scene and create a temporary instance of your UIActionMenu anywhere. It’ll appear as an empty box. To visualize the menu, instantiate a UIActionList as a child of it, and several UIActionButton as children of UIActionList.
Select the UIActionMenu and move it to a suitable place in the viewport.
We need to copy this position to the menu’s source scene,
UIActionMenu.tscn. To do so, click the tool icon in the top-right of the Inspector and click on Copy Params.
Delete the temporary UIActionMenu node from the CombatDemo scene.
Then, open the
UIActionMenu.tscn scene, select the UIActionMenu node there, and in the Inspector, click the tool icon followed by Paste Params
Delete the UIActionMenu from the scene.
To put our menu to use, we have to add code to the
ActiveTurnQueue class. We’re going to:
_player_select_action_async()function to instantiate and use the menu.
ActiveTurnQueue.gd and update the code like so:
# Stores a reference to the UIActionMenu scene. You have to assign it in the Inspector. export var UIActionMenuScene: PackedScene # var is_active := true setget set_is_active #... func _player_select_action_async(battler: Battler) -> ActionData: # Every time the player has to select an action in the turn loop, we instantiate the menu. var action_menu: UIActionMenu = UIActionMenuScene.instance() add_child(action_menu) # Calling its `open` method makes it appear and populates it with action buttons. action_menu.open(battler) # We then wait for the player to select an action in the menu and to return it. var data: ActionData = yield(action_menu, "action_selected") return data
In the game scene, select the ActiveTurnQueue and drag
UIActionMenu.tscn onto the UI Action Menu Scene in the Inspector.
If you set everything right and play the game, you should see the menu pop up on the player’s turn. You still cannot choose a target: the action will automatically apply to the first opponent.