Table of Contents
Introduction
In this post we’re going to continue our series on Godot Fundamentals. We’ll be creating a new main menu in which our game will start at and allow us to start and quit our game, as well as introduce some scene transitions when leaving and entering our menu.
Video
Create Our Main Menu Scene
We'll begin by creating our new Main Menu scene based on a Control node, we'll save it a new folder called menus
in our scenes/ui/
folder. We'll name this scene scenes/ui/menus/main_menu.tscn
and give our Root node a name MainMenu
.
To compose our scene we'll start with a ColorRect for our BG, we'll give this a color of #11161f
and set it's anchor preset to Full Rect.
We'll add another child of our Root node as a sibling to our BG; this will be a MarginContainer and will have an anchor preset of Full Rect as well. We'll set the margin to 32px on all sides through the theme_override_constants
values.
Next we'll want to align our title text as well as button based options in a vertical alignment using the VBoxContainer node. This will use the container sizing for fill in both vertical and horizontal alignment.
We'll add inside our VBoxContainer a Label and set it's Horizontal and Vertical Alignment to Center and the container sizing to also be fill in both settings, with expand set for the vertical. We'll also want to bump our font size to 32px by setting it in the theme_override_font_sizes/font_size
. I'm setting the text for this label to Godot Fundamentals as that's our main project here, name it whatever you'd like, or maybe you've got a fun logo to use in it's place!
Next we'll add a VBoxContainer as a child of our current VBoxContainer to contain our Buttons. We'll name this node ButtonsVBox
. This new container should have it's container sizing set to Fill on Horizontal and Shrink End on Vertical with Expand enabled.
Lastly let's add our Button children to this new VBoxContainer. We'll create one for our Start Game
called StartGameButton
and Exit
called QuitButton
. Each Button should have it's text set respectively. For the container sizing of our Buttons we'll set them to Shink Center on both Horizontal and Vertical.
Focus Our Buttons
To improve on our menu navigation, we can enable the Focus elements by defining the layout and tabbing expectation for each of our buttons.
We'll be setting the Neighbor direction that is next in logical order, and works for wrapping around.
We can also use this approach for Tab behavior by using the Next and Previous fields. Define the correct order for Neighbors as Start goes to Exit both Top and Bottom, as well as Next and Previous.
Our Exit should goto Start via the Top and Bottom Neighbors and The Next and Previous.
We'll introduce more options when we update our in game menu later.
Theme Our Menus
Currently our font and buttons are a bit basic out of the box. To improve this we can setup a new Theme by selecting the theme proprty and creating a new Theme. Let's save this in a new folder called themes
in our assets
folder -> assets/themes/default_theme.tres
.
This will open up the Theme editor on the bottom panel and in the inspector we'll see the types of overrides we've introduce as part of our theme. We can start by setting our default font and font size.
For this example I'm using the Early GameBoy font that I got from dafont.com . You can use any .ttf type font. I'll be storing mine in the assets/fonts
folder and setting our default font to this .ttf once imported. I'll also set the default font size to 16px.
Next I'll add an override for our Buttons for styling and color. Add an override by click the + icon in the Theme editor to add the Button Item Type. We can then choose the color and style overrides for the different types/states of our button.
I'll be setting a new focus, hover, and pressed color (first tab). Also inside our styles (second to last tab) i'll be overriding all to use a StyleBoxEmpty.
Save these changes, and now you can use this same theme on all your Control nodes that you want to use the same Theme. We'll be covering Themes in more detail in a later post, so stay tuned for that in the future.
You should have a main menu that looks like this:
Wiring It Up
Now that we have the visuals, let's add some signals and wire up how we want to interact with this scene.
A practice that I like doing with menu's is that I'll setup the scene to handle button presses as normal and the root node will delegate the intended behavior to the outside world. For example we have 2 outcomes in this scene:
- Start Game
- Exit Game
We can have the MainMenu
scene emit a signal when we want to start_game
and for our exit_game
we don't need to notify of the intent, just tell the scene tree to quit.
get_tree().quit()
So let's start our script for the MainMenu
-> scenes/ui/menus/main_menu.gd
:
# main_menu.gd
extends Control
signal start_game()
func _on_start_game_button_pressed() -> void:
start_game.emit()
hide()
func _on_quit_button_pressed() -> void:
get_tree().quit()
Another thing we can do here is set our expected default focus button. We can add a new function that handles focusing a button for us and call it when our node is ready as well as whenever the menu comes back into visibility (more on this later).
Let's give our ButtonsVBox
a UniqueAccess name and bind that in our script, then we'll create ouf new function for focus_button
. We also want to connect our visibility_changed signal to a function as well to check if we are visible we want to also call the focus_button
function.
# main_menu.gd
extends Control
...
@onready var buttons_v_box: VboxContainer = %ButtonsVBox
func _ready() -> void:
focus_button()
...
func _on_visbility_changed() -> void:
if visible:
focus_button()
...
func focus_button() -> void:
if buttons_v_box:
var button: Button = buttons_v_box.get_child(0)
if button is Button:
button.grab_focus()
Integrate Main Menu and UI
Next let's update our UI
scene to integrate the new Main Menu scene. We'll leverage our UI
's script to wrap our expected contract of behavior for when our MainMenu
's signals are emitted and the behavior we expect at a UI
layer.
For example, when we select Start Game from the main menu, we want our UI
layer to play a transition, meanwhile informing our Game
scene that we want to populate our World
scene and start the game from there. More on this later.
If we open our UI
scene, we can add our MainMenu
as a child of our Control node. Let's open our UI
script and create a reference to our MainMenu
using the Access Unique Name and bind it in our code.
# ui.gd
@onready var main_menu = %MainMenu
We'll also want to create a start_game
signal
# ui.gd
signal start_game()
Next let's wire up our MainMenu
's signal for start_game
to our _on_main_menu_start_game
function.
# ui.gd
func _on_main_menu_start_game():
start_game.emit()
For now this just emits it's own signal for the UI
layer, but we'll be adding additional behavior here as well, such as ensuring transitions complete their animations and other UI
elements are properly visible.
Refactor Game Scene
Now that our UI
contains a MainMenu
scene, we want our Game
's default scene to no longer jump right into the World
with our Tank
. Instead we'll refactor our Game
's scene to instantiate the scenes once we receive the start_game
signal from our UI
.
To get start started let's open our Game
scene and remove the World
and Tank
children. This will break our connection for our Game
's exported variable for tank as well as our Camera
's target exported variable.
To fix our Game
's exported variables let's create a TankScene and WorldScene exported variables of type PackedScene
and refactor our tank variable to no longer be exported, it'll be a standard class variable now. We'll also create a world variable:
# game.gd
@export var TankScene: PackedScene
@export var WorldScene: PackedScene
...
var tank: Tank
var world: World
Now that our Tank
and World
scenes are exported, we can instantiate them once we call to the start_game
function, this will also handle connecting our signals previously done in the _ready
function, which can be removed entirely:
# game.gd
func start_game():
world = WorldScene.instantiate()
add_child(world)
move_child(world, 0)
tank = TankScene.instantiate()
world.add_child(tank)
tank.position = Vector2(922, 623)
tank.collected.connect(ui._on_collected)
tank.reload_progress.connect(ui._on_reload_progress)
tank.reloaded.connect(ui._on_reloaded)
tank.has_control = false
camera.change_target(tank)
# camera.gd
@export var target: Node = null:
set(tar):
fallback_target = tar if fallback_target == null else fallback_target
target = tar
When this function is called, it create's an instance of World
and adds it to our Game
's scene and move's it to the first index. We'll then create our Tank
and add it World
's scene. Our Tank
position will be set and we'll wire up the signals, then change our camera target to our Tank
.
We update our Camera
to update our fallback_target when our target when it's initially set.
Since we deleted our _ready
function, we'll be calling our start_game
function by connecting our UI
's start_game
signal to our _on_ui_layer_start_game
and calling the start_game
function.
func _on_ui_layer_start_game():
start_game()
In Game Menu Improvements
Next we'll add some improvements to our in game menu to allow us to navigate back to our main menu and quit the game, as well as properly pausing our game when our menu is open.
Let's start by extracting our UI
's Menu control node into it's own scene, we'll save it as scenes/ui/menus/game_menu.tscn
.
Let's rename the VBoxContainer
that contains our GridContainer
to ButtonsVBox
and we'll add our children buttons to this VBoxContainer
.
First we'll create our ReturnToGameButton
and move it to before our GridContainer
, then add our MainMenuButton
and QuitButton
after the GridContainer
. Set all of these Button
s Text Behavior to Left Alignment.
We'll also setup the Focus Neighbors for each button so that our Navigation is fluid. We can tie our Button's neighbors to also focus the Slider
s for SFX and BG.
Since we've refactored our GameMenu
out of our UI
scene, we must also migrate over the Audio based connections and functions into our GameMenu
script:
# game_menu.gd
extends Control
...
@onready var SFX_BUS_ID = AudioServer.get_bus_index("SFX")
@onready var MUSIC_BUS_ID = AudioServer.get_bus_index("Music")
@onready var buttons_v_box = %ButtonsVBox
func _on_sfx_slider_value_changed(value):
AudioServer.set_bus_volume_db(SFX_BUS_ID, linear_to_db(value))
AudioServer.set_bus_mute(SFX_BUS_ID, value < .05)
func _on_bg_slider_value_changed(value):
AudioServer.set_bus_volume_db(MUSIC_BUS_ID, linear_to_db(value))
AudioServer.set_bus_mute(MUSIC_BUS_ID, value < .05)
func focus_button() -> void:
if buttons_v_box:
var button: Button = buttons_v_box.get_child(0)
if button is Button:
button.grab_focus()
Notice that we also create a reference to our ButtonsVBox
and bring in our focus_button
function to grab the first button's focus by default. We'll also tie that function in when our GameMenu
s visibility changes and connect our signals for when our buttons are pressed, emitting the proper signals for when our GameMenu catches our button presses.
# game_menu.gd
signal return_to_game()
signal main_menu()
func _on_return_to_game_button_pressed():
return_to_game.emit()
func _on_main_menu_button_pressed():
main_menu.emit()
func _on_quit_button_pressed():
get_tree().quit()
func _on_visibility_changed():
if visible:
focus_button()
Wire Game Menu to UI
Back in our UI
we will wire up our GameMenu
's new signals to handle returning to the main menu and opening/closing the game menu.
We'll create new signals for our menu opening/closing, as well as when our UI
quits to the menu. We'll update our _input
function to only open our game menu if our main menu is not visible, and emit signals when opening/closing our game menu. We can also wire up our signals to the proper functions:
# ui.gd
signal menu_opened()
signal menu_closed()
signal quit_to_menu()
func _input(event):
if !main_menu.visible and event.is_action_pressed("ui_cancel"):
menu.visible = !menu.visible
if menu.visible:
menu_opened.emit()
else:
menu_closed.emit()
func _on_menu_main_menu():
menu.hide()
quit_to_menu.emit()
main_menu.show()
func _on_menu_return_to_game():
menu.hide()
menu_closed.emit()
Next we'll wire our UI
's new quit_to_menu
signal to our Game
's _on_ui_layer_quit_to_menu
function to free our World
and reset the camera.
# game.gd
func _on_ui_layer_quit_to_menu():
if world:
world.queue_free()
world = null
tank = null
camera.reset()
# camera.gd
func reset() -> void:
target = null
fallback_target = null
Pause Game on Menus
Now that we are opening/closing our menu in game, we want to pause our game to prevent movement and camera panning around. To pause our game, we must first define which of our nodes should respect our pause state, and what nodes we allow continue processing as usual. Read more about Pausing games and process mode from the Godot Documentations .
We can implement pausing using Godot's built in process_mode capability by simply modifying the property inside the inspector. For example we want our World
scene to have it's default process_mode set to Pausable in the inspector.
This mode will tell this node to not run it's process functions while our Scene Tree's paused variable is true. We can set our UI
's process_mode to Always which means the UI
will always be processed, regardless of the Scene Tree's paused state.
If we want our BG music to always play, we'll set the AudioStreamPlayer
's process_mode to Always.
Next we'll toggle the pause state when we handle our UI
's signals within our Game
script and update our start_game
function to also unpause our tree when we start the game, after we set our Camera
's target:
# game.gd
func start_game():
...
camera.change_target(tank)
get_tree().paused = false
func _on_ui_layer_menu_closed():
get_tree().paused = false
func _on_ui_layer_menu_opened():
get_tree().paused = true
Scene Transitions
Alright, now let's setup a transition animation between our scene changes. Inside our UI
scene add a new child in our Control
node of type ColorRect
, we'll call this Transition and grant it Access Unique Name. We'll set the color to a solid black.
Inside the AnimationPlayer
we'll create a new Animation
called screen_transition
. This will track the Transition
's modulate property to go from the default solid white keyframed on frame 0, allowing the RESET track to be created. Then on our final frame (1s) we'll transition the property to a fully transparent color and keyframe it.
We'll also modify the Easing to 2.0 on our first keyframe, so that we ramp more towards the end of the animation.
Now we'll add the transition call inside our UI
's script when we start_game and when we return the the main_menu:
# ui.gd
func _on_main_menu_start_game():
start_game.emit()
transition.show()
animation_player.play("screen_transition")
await animation_player.animation_finished
transition.hide()
func _on_menu_main_menu():
if animation_player.is_playing():
await animation_player.animation_finished
menu.hide()
transition.show()
animation_player.play_backwards("screen_transition")
await animation_player.animation_finished
transition.hide()
quit_to_menu.emit()
main_menu.show()
func _on_menu_return_to_game():
if animation_player.is_playing():
await animation_player.animation_finished
menu.hide()
menu_closed.emit()
Note that we are awaiting the animation_player's animation_finished signal to be emitted before hiding or finishing out the transition.
Recap
We've successfully created a Themed menu with transitions between our Main Menu and our Game World. As always, this is just an introduction to the concept of scene transition and the methods in which we've wrapped our logic. Be sure to check out the project's GitHub project for the source code! If you have questions or ideas on how to leverage these concepts, be sure to jump into our growing Discord community and start a chat!
Happy Coding.