Extra Map Info

Both a new best time and a new best travel distance.

This is the tenth 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 Game HUD and adds extra map info to the HUD.

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

Detector Count

We're going to add the map's detector count to the HUD. We'll show both the current valid count and the total count. This functions as a completion metric to reinforce the player's progress on the map.

Add a new Label node to hud.tscn by duplicating the one for the map time. Change its Control Layout › Anchors Preset to Bottom Left to make it snap to the bottom of the window. Then manually set its Layout › Transform › Position X component back to 324. This puts it at the bottom right corner of the window, with a one-tile margin on its right. Set its default text to 00 / 00.

An an exported variable to hud.gd hook it up the label via the inspector.

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

Then give it a show_map_detector_count() function that sets the label's text. The function needs two int parameters: one for the valid and one for the total detector count. Use %d / %d for the text. To fill in multiple placeholders we have to provide them in an array, in the desired order.

func show_map_detector_count(valid: int, total: int) -> void:
	map_detector_count_label.text = "%d / %d" % [valid, total]

Call this function in map.gd at the end of _ready(), passing it the required counts. The valid count should be zero here, but we'll use its variable for consistency.

func _ready() -> void:
	…
	
	Main.map_loaded(self)
	HUD€.show_map_info(self)
	HUD€.show_map_detector_count(
			valid_detector_count, registered_detector_count
	)

Also call the function at the end of _on_detector_validity_changed() to keep the HUD up to date.

func _on_detector_validity_changed(valid: bool) -> void:
	if valid:
		…
	else:
		valid_detector_count -= 1
	
	HUD€.show_map_detector_count(
			valid_detector_count, registered_detector_count
	)

The detector counts now gets updated while we play the game.

Valid and total detector count in game.

Travel Distance

Let's add one more metric to our HUD: the distance that the player has traveled on the map, expressed in pixels. Add a label for it to hud.tscn, by duplicating the map name label, setings its default text to 0px, changing its anchors to bottom left, and then settings the X component of its position back to 16.

Add an exported variable for the label to hud.gd and hook it up.

@export var map_detector_count_label: Label

Then give it a function to show the travel distance, with a float parameter, setting the label's text to %dpx, using the provided distance.

func show_travel_distance(distance: float) -> void:
	map_detector_count_label.text = "%dpx" % distance

Although it's the distanced traveled across a map, we'll let player_character.gd keep track of it this time, as travel is a player thing. Give it a Vector2 variable to keep track of its last position and a float variable for its current travel distance, set to zero by default.

var last_position: Vector2
var travel_distance := 0.0

Set the last position to the player's current position when it is ready. Also show the travel distance here, which resets the label to zero.

func _ready() -> void:
	last_position = position
	HUD€.show_travel_distance(travel_distance)

Now we have to add the distance from the last position to the current position at the end of _physics_process(), then set the last position to match the current position.

func _physics_process(_delta: float) -> void:
	get_player_input()
	if move_and_slide():
		resolve_collisions()
	
	travel_distance += last_position.distance_to(position)
	last_position = position

We must show the new distance as well, but we only really need to do this when a new frame is drawn, not every physics step. So let's do this in _process() instead.

func _process(_delta: float) -> void:
	HUD€.show_travel_distance(travel_distance)
Travel distance in game.

Best Travel Distance

Now that we know the travel distance we can also keep track of the best travel distance for each map. We'll then show the best time and travel distance together. Adjust hud.tscn, renaming the NewBestMapTime node to NewBestMapScores or something similar to indicate its broader scope, and add New Best Travel Distance to it default text, on a new line. Also change its Label › Horizontal Alignment to Center.

In hud.gd, rename the new_best_map_time_label variable to new_best_map_scores_label and adjust the code that uses it. This requires us to hook up the label again.

Moving on to the function, rename show_new_best_map_time() to show_new_best_map_scores() and add a parameter to it for the travel distance. Also remove the new_ prefix from the parameter to keep the code line short.

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

We now have two potentially best scores, but it is possible that we get a better travel distance without getting a better time. The opposite is also possible. To deal with this let's say that a providing a score of −1 to the function indicates that it is not a better score and should not be displayed. We can then check whether the scores are not negative. Let's add a code documentation comment to make this clear.

## Show new best scores, supply -1 to indicate no new best score.
func show_new_best_map_scores(time: int, travel_distance: int) -> void:

As we have two independent scores there can be four possible configurations: no best score, two best scores, and either only a best time or a best distance. We'll use a different string for each case. First, if both the time and travel distance scores are best then the string we use is "New best time: %ds\nNew best travel distance: %dpx" with both scores. Here \n (backslash followed by n) indicates a newline character. We check whether this is the case with two nested greater-than-or-equal-to zero if checks.

func show_new_best_map_scores(time: int, travel_distance: int) -> void:
	var s: String
	if time >= 0:
		if travel_distance >= 0:
			s = (
					"New best time: %ds\nNew best travel distance: %dpx" %
					[time, travel_distance]
				)
	
	new_best_map_scores_label.text = s
	new_best_map_scores_label.visible = true

If we have a time but not a distance then we use the old string for only a new best time.

	var s: String
	if time >= 0:
		if travel_distance >= 0:
			s = (
					"New best time: %ds\nNew best travel distance: %dpx" %
					[time, travel_distance]
				)
		else:
			s = "New best time: %ds" % time

If we don't have a time but do have a distance use the appropriate string to indicate that, using an elif else-if check. Otherwise there is no best score at all and we can return without showing anything.

	var s: String
	if time >= 0:
		if travel_distance >= 0:
			s = (
					"New best time: %ds\nNew best travel distance: %dpx" %
					[time, travel_distance]
				)
		else:
			s = "New best time: %ds" % time
	elif travel_distance >= 0:
		s = "New best travel distance: %dpx" % travel_distance
	else:
		return

Now we have to update map.gd. First define a key for the best travel distance.

const KEY_BEST_TIME := "best_time"
const KEY_BEST_TRAVEL_DISTANCE := "best_travel_distance"

Then rename its _update_best_time() function and its usage to _update_best_scores(). Then inside the function we'll always call HUD€.show_new_best_map_scores() but we set new_time to −1 if there is no new best time. We also have to pass along a travel time, using −1 for now.

func _update_best_scores() -> 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
	else:
		new_time = -1
	
	HUD€.show_new_best_map_scores(new_time, -1)

We can use the same approach used for the time to detect, store, and show the best travel distance. But we first have to retrieve the travel distance from the player character. To access the player let's find it the same way that we collect the detectors, so we don't have to manually hook it up per map nor rely on a specific node name. First, give the PlayerCharacter class name to player_character.gd.

class_name PlayerCharacter
extends CharacterBody2D

Then add a variable for the player to map.gd.

var player_character: PlayerCharacter

And if we got something other than a detector in _ready() then check if we got the player character instead. If so assign it to our variable.

func _ready() -> void:
	for child_node in get_children():
		var detector := child_node as Detector
		if detector:
			registered_detector_count += 1
			detector.validity_changed.connect(_on_detector_validity_changed)
		else:
			var pc := child_node as PlayerCharacter
			if pc:
				player_character = pc

Now we can also check the player's travel distance in _update_best_scores().

func _update_best_scores() -> 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
	else:
		new_time = -1
	
	var new_travel_distance := floori(player_character.travel_distance)
	if (
			not map_save_data.has(KEY_BEST_TRAVEL_DISTANCE)
			or new_travel_distance < map_save_data[KEY_BEST_TRAVEL_DISTANCE]
	):
		map_save_data[KEY_BEST_TRAVEL_DISTANCE] = new_travel_distance
	else:
		new_travel_distance = -1
	
	HUD€.show_new_best_map_scores(new_time, new_travel_distance)
New best map scores.

All four corners of our game window now contain some information. We're also storing two best scores per map. We won't make the statistics of our game more complex than this, but in the next tutorial we'll pay a bit more attention to the best scores.