Game HUD

Setting a new record.

This is the ninth tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Saving Progress and adds a simple game HUD to show map info.

This tutorial uses Godot 4.4, the regular version, but you could also use the .NET version.

HUD Scene

We're going to display some information about the map that the player is currently on. We'll do this by adding a new hud.tscn scene to our game. HUD stands for heads-up display. This is going to be another user interface scene, but this time we choose the Other Node option for its root node and pick CanvasLayer. Set its CanvasLayer › Layer › Layer property to 3 so it is drawn on top of everything else.

Add a child Label node for the map name. Use Map Name for its placeholder text. We reduce its font size by enabling Control › Theme Overrides › Font Sizes › Font Size and setting it to 11. This will make a single line of text take up 16 pixels vertically, matching our tile size.

Font size override.

The label is positioned at the top left of the window. We'll keep it there, but add a margin of one tile to its left. This can be done by setting the X component of its Layout › Transform › Position to 16.

Map name label.

Add the scene to the autoload list, named HUD. This will make the dummy map name show up when running the game.

Map name in game.

Map Names

To give a name to our maps we'll add an exported map_name variable to map.gd, set to Map by default. Let's also give it the GameMap class name.

class_name GameMap
extends Node

@export var map_name := "Map"

Open the map scenes and give them appropriate names. I simply named our two maps The First Map and The Second Map.

As our map script is now a proper class and will contain more useful data in the future, let's pass along the map object itself when calling map_loaded() in _ready() instead of only its scene file path.

	Main€.map_loaded(self)

Adjust map_loaded() in main.gd to match, now retrieving the path there.

func map_loaded(map: GameMap) -> void:
	current_map_path = map.scene_file_path
	
	var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
	game_save_file.store_var({
		KEY_MAP_PATH: current_map_path,
		KEY_GAME_SAVE_VERSION: GAME_SAVE_VERSION,
	})

We also want to pass the map to the HUD to show its info. Attach a new script to the HUD root node named hud.gd. Give it an exported variable to hook up the map name label and a show_map_info() function that sets the label's text to a given map's name.

extends CanvasLayer

@export var map_name_label: Label


func show_map_info(map: GameMap) -> void:
	map_name_label.text = map.map_name

Also, we should initially hide the HUD, until map info has to be shown. We can do this by setting its visible property to false when the HUD is ready and setting it back to true when showing map info.

func _ready() -> void:
	visible = false


func show_map_info(map: GameMap) -> void:
	map_name_label.text = map.map_name
	visible = true
Can't we set the HUD to invisible via its inspector?

Yes, but that would make editing it inconvenient. So we won't rely on the scene being set invisible manually.

Now we can show the map info when it is ready in map.dg. Do this after indicating that the map is loaded in _ready().

func _ready() -> void:
	…
	
	Main€.map_loaded(self)
	HUD€.show_map_info(self)
Map-specific name.

Time On Map

Besides the map's name, let's also display how long the player has been on the map. Add another label to the scene for this by duplicating the one for the map name. Set its X size to 60, and its X position to 324 so it is aligned with the right side of the map, again with a margin of one tile. Set its text to 0s and its Label › Horizontal Alignment to Right.

Hook the label up to a new exported variable in hud.gs. Add a show_map_time() function with a float time parameter that initially sets the label's text to 0s. Call this function in show_map_info() with a time of zero.

@export var map_name_label: Label
@export var map_time_label: Label

…

func show_map_info(map: GameMap) -> void:
	map_name_label.text = map.map_name
	show_map_time(0.0)
	visible = true


func show_map_time(time: float) -> void:
	map_time_label.text = "0s"

To display the given time we'll use a format string with a placeholder. Replace the 0 with %d to indicate that we want to show a decimal integer. We fill in the placeholder by adding the % operator after the string, followed with the time. As it's an integer decimal its fractional part will not be included in the final string.

func show_map_time(time: float) -> void:
	map_time_label.text = "%ds" % time

To make the time progress add a current_map_time variable to map.gd, set to 0.0 by default. Then add a _process() function that adds the time delta to the current map time and then calls the HUD to show it.

var valid_detector_count := 0
var current_map_time := 0.0


func _process(delta: float) -> void:
	current_map_time += delta
	HUD€.show_map_time(current_map_time)
Current map time.

Holding on to Game Save Data

Now that we're tracking map time it would be nice if we could remember the best completion time of each map. To do so we have to include it in our game save data. To facilitate this we'll keep track of the game save data in main.gd, making game_save_data a top-level variable set to an empty dictionary by default. As the map path is part of the save data we no longer need a separate variable for it.

#var current_map_path: String
var game_save_data := {}

Add a _ready() function that loads the save file if it exists and resets the game save data to an empty dictionary if it is an incompatible version. We also set the game save version at the end.

func _ready() -> void:
	if FileAccess.file_exists(GAME_SAVE_PATH):
		var game_save_file := FileAccess.open(
				GAME_SAVE_PATH, FileAccess.READ
		)
		game_save_data = game_save_file.get_var() as Dictionary
		if game_save_data[KEY_GAME_SAVE_VERSION] > GAME_SAVE_VERSION:
			print("Tried to load unknown future save file format!")
			game_save_data = {}
	
	game_save_data[KEY_GAME_SAVE_VERSION] = GAME_SAVE_VERSION

Now we no longer have to load the save file in load_continue_map().

func load_continue_map() -> void:
	if not can_continue():
		print("Tried to continue without save file!")
		return
	
	#var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.READ)
	#var game_save_data := game_save_file.get_var() as Dictionary
	#if game_save_data[KEY_GAME_SAVE_VERSION] > GAME_SAVE_VERSION:
		#print("Tried to load unknown future save file format!")
		#return
	
	var map_path := game_save_data[KEY_MAP_PATH] as String
	if (
			not map_path.begins_with(MAP_PATH_PREFIX)
			or map_path.contains("..")
	):
		print("Invalid map path!")
		return
	load_map(map_path)

Whether we can continue now depends on whether the key map path is in the save data.

func can_continue() -> bool:
	return game_save_data.has(KEY_MAP_PATH)

When loading the next map we fetch the map path from the game save data.

func load_next_map() -> void:
	var split_path := (game_save_data[KEY_MAP_PATH] as String).split(".")
	…

And when a map has loaded we store its file path in the game save data and then directly write the existing game save data to the save file.

func map_loaded(map: GameMap) -> void:
	game_save_data[KEY_MAP_PATH] = map.scene_file_path
	
	var game_save_file := FileAccess.open(GAME_SAVE_PATH, FileAccess.WRITE)
	game_save_file.store_var(game_save_data)

Save Data Per Map

As the map time is per map we'll have to support per-map save data. Add a dictionary variable for this to map.gd.

var current_map_time := 0.0
var map_save_data: Dictionary

We'll collect all map-specific data in a dictionary that we put in the game save data with the per_map_data key. When main.gd is ready, check if this dictionary exists in the loaded data and if not create a new one.

const MAP_PATH_PREFIX := "res://maps/"
const KEY_PER_MAP_DATA := "per_map_data"

…

func _ready() -> void:
	…
	
	game_save_data[KEY_GAME_SAVE_VERSION] = GAME_SAVE_VERSION
	if not game_save_data.has(KEY_PER_MAP_DATA):
		game_save_data[KEY_PER_MAP_DATA] = {}

Now when a map is loaded we also check if the per-map-data has an entry for that map, using its path as its key. If not we use an empty dictionary. Assign that to the map's own save data. Thus a map's save data is available after it has called map_loaded().

func map_loaded(map: GameMap) -> void:
	var map_path := map.scene_file_path
	game_save_data[KEY_MAP_PATH] = map_path
	var per_map_data := game_save_data[KEY_PER_MAP_DATA] as Dictionary
	if not per_map_data.has(map_path):
		per_map_data[map_path] = {}
	map.map_save_data = per_map_data[map_path]
Should we increment our game save version?

This isn't necessary, because although we added per-map data to the save file it works fine if this data is missing and an older game version will simply ignore it.

Best Map Time

To keep track of the best completion time of the map we'll store it in its save data with the best_time key. Add a constant for this key to map.gd.

const KEY_BEST_TIME := "best_time"

Add a function for private use that updates the best time, if applicable. This is the case when there either isn't a best time yet or the current time is shorter than the best time so far. If we have a new best time put it in the save data and print it.

func _update_best_time() -> void:
	if (
			not map_save_data.has(KEY_BEST_TIME)
			or current_map_time < map_save_data[KEY_BEST_TIME]
	):
		map_save_data[KEY_BEST_TIME] = current_map_time
		print("new best time: %d" % current_map_time)

Let's limit ourselves to whole seconds for the best time, disregarding the fractional part of the current map time, by passing it through floori() so we get an integer.

func _update_best_time() -> void:
	var new_time := floori(current_map_time)
	if (
			not map_save_data.has(KEY_BEST_TIME)
			or new_time < map_save_data[KEY_BEST_TIME]
	):
		map_save_data[KEY_BEST_TIME] = new_time
		print("new best time: %d" % new_time)

Call this function directly before we transition to the next map.

func _on_detector_validity_changed(valid: bool) -> void:
	if valid:
		valid_detector_count += 1
		if valid_detector_count == registered_detector_count:
			_update_best_time()
			Main€.load_next_map()
	else:
		valid_detector_count -= 1

A message now gets printed when we achieve a first or new best time for a map and it gets saved as well.

Don't we need to copy the map save data back to the game save data?

We don't need to do this, because the map's save data variable references the same dictionary object that is contained in the game save data.

Showing Best Map Time

The last thing we'll do is replace the printed message with an in-game notification of a new best time. Add a new default label for this to hud.tscn with its Layout › Anchors Preset set to Center. Hook it up to a new exported variable in hud.gd.

@export var map_name_label: Label
@export var map_time_label: Label
@export var new_best_map_time_label: Label

This label should only show up when a new best time is achieved, so make it invisible when the HUD is ready.

func _ready() -> void:
	visible = false
	new_best_map_time_label.visible = false

Add a function to show a new best map time, given the new time. Have it set the label's text to New best time: %ds with the new time applied to it, then make it visible. As we use integers for the best time the parameter's type is int.

func show_new_best_map_time(new_time: int) -> void:
	new_best_map_time_label.text = "New best time: %ds" % new_time
	new_best_map_time_label.visible = true

We'll keep the new-best-time label visible until the info of the next map needs to be shown. This means that the label will remain visible during the map-exit animation and until the next map has been loaded.

func show_map_info(map: GameMap) -> void:
	map_name_label.text = map.map_name
	map_time_label.text = "0s"
	visible = true
	new_best_map_time_label.visible = false

Finally, replace the printing of the new best time in map.gd with a call to the HUD.

func _update_best_time() -> void:
	var new_time := floori(current_map_time)
	if (
			not has_best_time()
			or new_time < get_best_time()
	):
		map_save_data[KEY_BEST_TIME] = new_time
		#print("new best time: %d" % new_time)
		HUD€.show_new_best_map_time(new_time)
New best time achieved.

Now that we're giving some feedback about the current map and how the player is performing our app is starting to feel more like a real game. The next tutorial is Extra Map Info.