Multiple Maps

The first map is the easiest.

This is the sixth 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 Detectors and adds support for multiple maps to it.

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

Detecting Map Completion

This time we're going to add a second map to our game. The idea is that when the player completes the objectives of a map the game switches to the next map. So before that switch can happen we must determine whether the current map has been completed. In the previous tutorial we added detectors that track whether there are any movable objects on top of them. We will use these detectors to trigger map completion.

Custom Signal

A single detector should only be concerned with its own state. It doesn't need to know about the role it plays in determining map completion. But we need to somehow communicate whether the detector is in its valid state to the rest of the game. We will do this by adding a custom signal to the detector.gd script.

We'll make the detector emit a signal whenever its validity state changes. A custom signal is defined with the signal keyword, followed by the signal's name, for which we'll use validity_changed. The convention is to define signals at the top of the script, above its variables and functions.

extends Area2D

signal validity_changed

The signal will be emitted each time the state changes, so each time the detector becomes valid and also when it becomes invalid. To indicate what kind of change happened we add a valid parameter of type bool to the signal, as if it were a function definition. The bool type is shorthand for a boolean, which can be either true or false.

signal validity_changed(valid: bool)

Our custom signal should now show up in the detector's signal list.

Custom signal.

To emit the signal we call its emit() function. Do this at the appropriate places in _on_body_entered() and _on_body_exited(), passing a boolean value to it corresponding to the detector's new state.

func _on_body_entered(_body: Node2D) -> void:
	if object_count == 0:
		sprite.modulate = valid_color
		validity_changed.emit(true)
	object_count += 1


func _on_body_exited(_body: Node2D) -> void:
	object_count -= 1
	if object_count == 0:
		pulse_progress = 0.0
		validity_changed.emit(false)

Tracking Valid Detectors

The map is considered finished when all its detectors are valid. To check this we have to register all connectors in the scene somewhere, connecting to their signals. We will do this in a new map.gd script that extends Node which we attach to the root Map node of our map.tscn scene.

We need to keep track of both how many detectors we registered and how many detectors are valid. Add two variables to count these.

extends Node

var registered_detector_count := 0
var valid_detector_count := 0

We're not connecting to the signals via the editor, because that requires tedious manual work and is prone to errors due to forgetting to connect some detectors. Instead, we define the _on_detector_validity_changed(valid: bool) function manually. If the state is valid then we increment the valid detector count and then check if it matches the total registered count. If so, for now we simply print that the map is completed. If the state is not valid then we decrement the valid detector count instead.

func _on_detector_validity_changed(valid: bool):
	if valid:
		valid_detector_count += 1
		if valid_detector_count == registered_detector_count:
			print("Map completed.")
	else:
		valid_detector_count -= 1

Next, we're going to connect to the signals via code. This requires us to look for child nodes that are detectors. To make this possible give the detector.gd script the Detector class name.

class_name Detector
extends Area2D

Now we can add a _ready() function to map.gd that loops through all child nodes to finds the detectors when the scene is ready. We do this with a for in loop over the result of get_children(). This gets us all direct child nodes of our map's root node, so our detectors must be direct children of the root for this to work. We could make this recursive, but there is no compelling reason to put detectors in a deeper hierarchy for our simple map.

func _ready() -> void:
	for child_node in get_children():
		pass

We try to cast each child to the Detector type and if that gives us a valid detector we can increment the registered detector count.

func _ready() -> void:
	for child_node in get_children():
		var detector := child_node as Detector
		if detector:
			registered_detector_count += 1

We also connect to the detector's signal, by invoking connect() on it, passing it our function. Note that this is a reference to the function of our map instance itself. We don't invoke the function here, so it doesn't get an argument list.

			registered_detector_count += 1
			detector.validity_changed.connect(_on_detector_validity_changed)

If we play our map now and push objects on all detectors we should see Map Completed. getting logged in the editor's Output panel.

Switching Maps

Now that we can detect map completion we can move on to switching maps.

Second Map

Before we can switch to another map we have to create a new one. First create a maps resource folder and move map.tscn into it. A new map can be created via the context menu of the FileSystem panel.

We need a map naming scheme to make it easy to work with multiple maps. Let's use the format map.number.tscn with a fixed three-digit number. So rename our map to map.001.tscn to indicate that it is the first map. Create a second map by duplicating it and renaming it to map.002.tscn.

Two map resources.

Let's edit map 001 so it's very quick to complete. I changed it to a simple map with the player in the middle, with one movable object and a detector directly above it.

Simple first map.

Note that each map keeps track of its own completion, so we could run whichever map we want directly from the editor and map completion will be detected correctly.

I accidentally simplified map 2 instead of 1, now what?

You can just change the numbers of the scenes. Then update the Main Scene in the project settings so the new map 1 is loaded by default.

Loading the Next Map

There are various ways we could manage map loading, but we want one that has low maintenance and can work from any starting map. So we're not going to define a specific sequence of maps, nor any direct relationship between maps. We'll instead rely on our map resource naming convention to automatically progress to the next map.

To support loading the next map from an arbitrary current map we're going to add a load_next_map() function to map.gd. To figure out which map to load next we first retrieve the current map's resource file path. This is done by invoking get_tree(), getting its current_scene, and getting its file_path from it. Let's print it at this point and do nothing else yet.

func load_next_map():
	var current_map_path := get_tree().current_scene.scene_file_path
	print(current_map_path)

We call this function when we detect map completion, instead of only printing that the map is completed.

func _on_detector_validity_changed(valid: bool):
	if valid:
		valid_detector_count += 1
		if valid_detector_count == registered_detector_count:
			load_next_map()
	else:
		valid_detector_count -= 1

When completing the first map we should see res://maps/map.001.tscn getting printed.

The next step is to extract the map number from its path. We do this by splitting the path on periods, by invoking split(".") on it. This gives us an array with its three parts. Let's print that instead.

func load_next_map():
	var current_map_path := get_tree().current_scene.scene_file_path
	var split_path := current_map_path.split(".")
	print(split_path)

Now we get ["res://maps/map", "001", "tscn"], so the number is the second array element, with index 1. Grab it, convert the string to an integer by callings its to_int() function, and add 1 to get the next map number. Then convert back to a string by passing it to str() and use that to replace the original number in the split path array.

	var split_path := current_map_path.split(".")
	var next_map_number := split_path[1].to_int() + 1
	split_path[1] = str(next_map_number)
	print(split_path)

That gives us ["res://maps/map", "2", "tscn"] which shows that we get the correct number, but with the wrong amount of digits. We can fix this by calling pad_zeros(3) on the string to add leading zeros to get to three digits.

	split_path[1] = str(next_map_number).pad_zeros(3)

Now we get ["res://maps/map", "002", "tscn"], which is what we want, but as an array instead of a string. We have to join the array elements together with periods between them to get the final path of the next map. This is done by calling join() on a string that we want to use as the separator, passing it an array.

	split_path[1] = str(next_map_number).pad_zeros(3)
	var next_map_path = ".".join(split_path)
	print(next_map_path)

We finally get the correct path, which is res://maps/map.002.tscn. We can load it by passing it to get_tree().change_schene_to_file().

	var next_map_path = ".".join(split_path)
	#print(next_map_path)
	
	get_tree().change_scene_to_file(next_map_path)

Deferred Loading

When completing map 1 we now load map 2. This works, however Godot complains that removing a collision object node during a physics callback is not allowed and will cause undesired behavior. This happens because we immediately change the scene in response to a physics event, caused by a movable object entering the area of a detector. To avoid this problem we will delay calling load_next_map(). This is done by calling call_deferred() on the function instead of calling it directly. That will delay the call until after all regular work has been done for the frame.

		if valid_detector_count == registered_detector_count:
			load_next_map.call_deferred()

Looping Back to First Map

We now correctly move on to the next map, but this only works for maps that exist. As we only have two maps we cannot progress to map 3 and finishing map 2 will result in an error.

We can determine whether a resource exists by calling ResourceLoader.exists() for a given path. If the resource does not exist we will simply loop back to the first map. So we set the number part of the path to 001 and recreate the next map path before we change the scene.

	var next_map_path = ".".join(split_path)
	
	if not ResourceLoader.exists(next_map_path):
		split_path[1] = "001"
		next_map_path = ".".join(split_path)
		
	get_tree().change_scene_to_file(next_map_path)

Now can we keep playing indefinitely, cycling between our two maps, or whichever maps you decide to add. Because our maps are tiny they're loaded nearly instantly and map completion immediately dumps the player in the next map. We'll improve this transition in the next tutorial.