Table of Contents
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.
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
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
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
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.
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.
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 ->
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:
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.
So let's start our script for the
# 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
# 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()
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
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
# ui.gd signal start_game()
Next let's wire up our
MainMenu's signal for
start_game to our
# 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.
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
To get start started let's open our
Game scene and remove the
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
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
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
start_game signal to our
_on_ui_layer_start_game and calling the
func _on_ui_layer_start_game(): start_game()
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
Let's rename the
VBoxContainer that contains our
ButtonsVBox and we'll add our children buttons to this
First we'll create our
ReturnToGameButton and move it to before our
GridContainer, then add our
QuitButton after the
GridContainer. Set all of these
Buttons 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
Sliders 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
# 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
GameMenus 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()
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
quit_to_menu signal to our
_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
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
# 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
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.
AnimationPlayer we'll create a new
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.
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!