Learn Godot 4 by Making a 2D Platformer — Part 16: Level Progression #1
christine
Posted on August 3, 2023
*You can find the links to the previous parts at the bottom of this tutorial.
With our enemy obstacles and pickups set up, we need to be able to progress onto the next level when we reach the top of our scene. To do this we can create a simple door that will trigger our level progression menu to show which will allow our player to restart their current level, or progress onto the next one.
WHAT YOU WILL LEARN IN THIS PART:
- UI creation.
- How to add scene transitioning via PackedScene resources.
- How to work with the String object.
- How to work with the Time object.
STEP 1: SCENE SETUP
In your project, create a new scene with an Area2D node at its root. Add a CollisionShape2D node, Sprite2D node, and a CanvasLayer node to this scene and save it underneath your Scenes folder as “LevelDoor”. Rename the nodes as indicated in the image below.
Change your Sprite2D node’s texture to “res://Assets/Kings and Pigs/Sprites/11-Door/Idle.png”, and change your CollisionShape to be as follows:
If you instance your door in your Main scene, it should be placed in a position where the player can run into the collision shape without being blocked.
STEP 2: SCENE UI
The next part is going to be quite a bit of UI work, so buckle up! To your UI canvas-layer node, add a new ColorRect node. Rename it to “Menu”.
To your Menu ColorRect add a new ColorRect called “TimeCompleted”, which contains two Label nodes as its children. This will show the time in seconds that our player took to complete the level.
Duplicate the TimeCompleted ColorRect twice so that we have elements to display our final score and ranking.
To your Menu node, add two Button nodes. These buttons will be used to continue or restart to our defined levels. Call one “RestartButton” and the other one “ContinueButton”.
Select your Menu node again and add a Label node and a ColorRect node. Rename the ColorRect node to “Container” and move your buttons and your other three ColorRects into this node.
To your Container node, add a Label node to it and move it to the top. Add another ColorRect and change its name to “Border”.
Then, to your Menu node add a Tilemap node with two AnimatedSprite2D nodes. Name one AnimatedSprite2D node to “AnimatedKing” and the other one to “AnimatedKing”. These are just visuals.
It should look like this so far:
1. Menu
I’m going to take you through from the top down. Select your Menu node and change its color property to #232f2b. Change its anchor preset to full-rect.
2. Container
Then select your Container node and change its color property to #30403a. Change its anchor-preset to full-rect, and its size (x: 1078, y: 590) and position (x: 35, y: 27).
3. Label
Select your Label node and text to “Level Complete!”. Change its anchor-preset to center-top, and its size (x: 504, y: 39) and position (x: 269, y: 80). Also change its font to “QuinqueFive” and the size to “30”.
4. Border
Select your Border node and change its color property to #455851. Change its anchor-preset to the center, and its size (x: 600, y: 350) and position (x: 239, y: 145).
5. TimeCompleted
Select your TimeCompleted node and change its color property to #ffffff00. Change its anchor-preset to h-center-wide, and its size (x: 1078, y: 0) and position (x: 50, y: 200).
6. TimeCompleted > Label
Select your Label node inside your TimeCompleted node and change its text to “Time:”. Change its anchor-preset to center-left, and its size (x: 336, y: 27) and position (x: 210, y: 0). Also change its font to “QuinqueFive” and the size to “20”.
7. TimeCompleted > Value
Select your Value node inside your TimeCompleted node and change its text to “0”. Change its anchor-preset to center-right, and its size (x: 400, y: 27) and position (x: 410, y: 0). Also change its font to “QuinqueFive” and the size to “20”. You also want to change its font color to #0000005a (underneath Theme Overrides > Colors > Font Colors).
8. Score
Select your Score node and change its color property to #ffffff00. Change its anchor-preset to h-center-wide, and its size (x: 1078, y: 0) and position (x: 50, y: 220).
9. Score > Label
Select your Label node inside your Score node and change its text to “Score:”. Change its anchor-preset to center-left, and its size (x: 144, y: 27) and position (x: 210, y: 40). Also change its font to “QuinqueFive” and the size to “20”.
10. Score > Value
Select your Value node inside your Score node and change its text to “0”. Change its anchor-preset to center-right, and its size (x: 400, y: 27) and position (x: 410, y: 40). Also change its font to “QuinqueFive” and the size to “20”. You also want to change its font color to #0000005a (underneath Theme Overrides > Colors > Font Colors).
11. Ranking
Select your Ranking node and change its color property to #ffffff00. Change its anchor-preset to h-center-wide, and its size (x: 1078, y: 0) and position (x: 50, y: 240).
12. Ranking > Label
Select your Label node inside your Ranking node and change its text to “Ranking:”. Change its anchor-preset to center-left, and its size (x: 144, y: 27) and position (x: 210, y: 80). Also change its font to “QuinqueFive” and the size to “20”.
13. Ranking > Value
Select your Value node inside your Ranking node and change its text to “0”. Change its anchor-preset to center-right, and its size (x: 400, y: 27) and position (x: 410, y: 80). Also change its font to “QuinqueFive” and the size to “20”. You also want to change its font color to #0000005a (underneath Theme Overrides > Colors > Font Colors).
14. ContinueButton
Select your ContinueButton node and change its text to “Continue”. Change its anchor preset to bottom-left, and its size (x: 260, y: 90) and position (x: 265, y: 385). Also, change its font to “QuinqueFive” and the size to “25”.
15. RestartButton
Select your RestartButton node and change its text to “Restart”. Change its anchor preset to bottom-left, and its size (x: 270, y: 90) and position (x: 545, y: 385). Also, change its font to “QuinqueFive” and the size to “25”.
16. TileMap
Now, for your TileMap node, assign the “Terrain.png” tilesheet to your TileSet resource and draw in the shapes of a tower.
It could look something like this:
17. AnimatedPig
Create a new animation for it with the “res://Assets/Kings and Pigs/Sprites/04-Pig Throwing a Box/Idle (26x30).png” resource. Add all the frames from the spritesheet. Change the FPS value to 8 and leave the looping on. Click the icon next to the trash can to enable this animation when the game starts. This will play this animation on the pig when the menu is shown.
18. AnimatedKing
Create a new animation for it with the “res://Assets/Kings and Pigs/Sprites/01-King Human/Idle (78x58)2.png” resource. Add all the frames from the spritesheet. Change the FPS value to 11 and leave the looping on. Click the icon next to the trash can to enable this animation when the game starts. This will play this animation on the pig when the menu is shown.
The complete UI layer should now look similar to this:
We want this UI Menu to slowly pop up on the screen when the player enters the door. To do this we need to add a new AnimationPlayer node to our scene.
To this AnimationPlayer node add a new animation called “ui_visibility”.
Add a new “Track Property” and assign it to your Menu node.
We want this Menu node to go from invisible to visible, and therefore we need to change its modulate value. The modulate value refers to the color applied to textures on this CanvasItem. We will change the modulate value’s alpha value, which is the node’s transparency.
Add two keyframes to this animation — one at point 0 and one at point 0.5.
Change the keyframe at point 0’s Color value to “ffffff00”. This will make it transparent.
Change the keyframe at point 0.5’s Color value to “ffffff”. This will make it visible.
We also want to change the update mode from discrete to continuous. Our update mode tells our animation track how often it should update our animation property. Continuous mode updates the property on each frame. This will make our node slowly go from invisible to visible.
Finally, change your animation’s length to 0.5.
Now if you play your animation, your menu should be made visible.
STEP 3: SCENE CHANGING
With our UI set up, we can go ahead and attach a new script to our LevelDoor scene. Save it under your Scripts folder.
We also want to assign our root node’s (Area2D) body_entered() signal to our script. This will trigger our menu to pop up when our player enters the collision body.
Finally, attach your Button node’s (both ButtonContinue and ButtonRestart) pressed() signals to your script. This tells our game what to do when the button is pressed.
Before we continue, we need to change our scene’s process mode. Each node in Godot has a “Process Mode” that defines when it processes. This means the node, or the scene will only “work” when a certain processing mode is assigned to it. The modes can be found and changed under a node’s Node properties in the inspector.
This is what each mode tells a node to do:
Inherit: The node will work or be processed depending on the state of the parent. If the parent’s process mode is pausable, it will be pausable, etc.
Pausable: The node will work or be processed only when the game is not paused.
When Paused: The node will work or be processed only when the game is paused.
Always: The node will work or be processed if the game is both paused or not.
Disabled: The node will not work, nor will it be processed.
We need our LevelDoor scene to work even if the game is paused. By default, the process mode is set to “Inherit”. This means it will process the same as its parent node. We need to change this to Always since we need this node to work when our game is running — because it needs to capture our player entering its collision — and we also need to have it working when the game is paused — which is when our Menu will be made visible. Let’s change our LevelDoor’s root node’s Process Mode to Always. You can find this option under Node > Process > Mode.
Now our LevelDoor scene will process and execute its functions both when the game is running and when the game is paused. In our script, we will pause our game when the Player’s body enters our doors collision body. When we press the restart or continue button, we will unpause the game so that we can continue playing the game in the next level. If we want to pause the game, we simply use the SceneTree.paused method. If the game is paused, no input from the player will be accepted, because everything is paused. That is unless we change our node’s process mode to “Pausable” or “Always”, which we already did!
### LevelDoor.gd
extends Area2D
func _on_body_entered(body):
if body.name == "Player":
# pause game
get_tree().paused = true
func _on_continue_button_pressed():
#unpause scene
get_tree().paused = false
func _on_restart_button_pressed():
#unpause scene
get_tree().paused = false
We also don’t want our Menu node to be visible by default, or when the player presses our buttons. In other words, when the game is not in a paused state, we want the Menu node to be hidden. If the game is paused, we want to show the Menu node. We will make this node visible by changing its visibility value in the code, and when our player enters the door, we will play our ui_visibility animation to make the Menu appear.
### LevelDoor.gd
extends Area2D
func _ready():
#hide menu on load
$UI/Menu.visible = false
func _on_body_entered(body):
if body.name == "Player":
# pause game
get_tree().paused = true
# show menu
$UI/Menu.visible = true
# animation to make menu's modular value visible
$AnimationPlayer.play("ui_visibility")
func _on_continue_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
func _on_restart_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
When we press our ButtonContinue, we want our player to be moved over to the next level. The next level will depend on the current level that we’re in. In our Main level, the next level will be Main_2, and its next level will be Main_3, and so forth. We don’t want to create a giant conditional statement to assign the next_level based on the current level, since that will be tedious and unnecessary. What we can do instead is export a variable that will hold a PackedScene resource. This creates a resource that has a reference to a scene file, such as “res://Scenes/Main.tscn”.
### LevelDoor.gd
# Allows us to change the scene (level) we want to go to in the editor properties
@export var next_level: PackedScene
By exporting this resource, we can go back to our level scenes where we instanced our LevelDoor scene, and choose the next level in the Inspector panel! To assign a new scene, select your Instanced LevelDoor in your Main scene and click on the PackedScene property in the inspector panel. Select the option to “Quick Load”. In my Main scene, I’m going to choose my next-level resource to be Main_2, and so forth.
If you were to run your scene now, and your player runs into the door collision, the menu should show but the player’s UI will show over it. Let’s fix this issue by hiding the player’s UI layer on_body_entered().
### LevelDoor.gd
#older code
func _on_body_entered(body):
if body.name == "Player":
# pause game
get_tree().paused = true
# show menu
$UI/Menu.visible = true
# make modular value visible
$AnimationPlayer.play("ui_visibility")
#hide the player's UI
body.get_node("UI").visible = false
Now, back to our ButtonContinue function. If our player presses the continue button, we want to change our current scene to our next scene. We will use the change_scene_to_packed() method, which changes the running scene to a new instance of the given PackedScene (which must be valid). In simple terms, this will change our current scene (Main) to our packed scene chosen in the editor (Main_2).
### LevelDoor.gd
#older code
func _on_continue_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
# Change to the next scene
get_tree().change_scene_to_packed(next_level)
Because we saved the name of our current scene in our Global script, we’ll have to do some magic to get the name of our Packed scene. This is because our current_scene is being saved as “Main” or “Main_2”, whilst our next_level is being saved as . This is because it is saving it as a scene resource, and not a scene file. To get the scene file path from our scene resource, we’ll need to extract our path via the resource_path method. This path can be an absolute or a relative file path.
### LevelDoor.gd
#older code
func _on_continue_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
# Change to the next scene
get_tree().change_scene_to_packed(next_level)
# gets the path of our packed scene resource
var path = next_level.resource_path
From this path, we can get the filename of the path. We will use the get_file() method for this, which returns the file name and extension of our path. For example, if the path is “res://folder/file.png”, path.get_file() would give you “file.png”.
Then, we’ll need to split our filename into an array, which will break apart our filename so that we can get the name value away from its extension. For example, if the file name is “file.png”, split(“.”) would give you an array like this: [“file”, “png”].
We’ll then access this filename via the array index [0]. All of this together will transform into “res://Scenes/Main_2.tscn” into “Main_2”, which we can then assign to our current_scene variable.
### LevelDoor.gd
#older code
func _on_continue_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
# Change to the next scene
get_tree().change_scene_to_packed(next_level)
# Extract the name of the packed scene file and update the current scene's name in the Global script
var path = next_level.resource_path
var scene_name = path.get_file().split(".")[0]
Global.current_scene_name = scene_name
Now if you run your scene, and you enter your door at the top — the menu should show. If you press the continue button, your level should change to the next level — and so forth.
With the restart button, we want to reload the current_scene that the player is in — which in our case is our Main scene. We can simply use the reload_current_scene() method which reloads the currently active scene.
### LevelDoor.gd
#older code
func _on_restart_button_pressed():
#unpause scene
get_tree().paused = false
#hide menu
$UI/Menu.visible = false
# Restart current scene
get_tree().reload_current_scene()
You will see from my screenshots above that my player returns a score, time, and ranking value. When our level ends, we want to display the time in seconds that the player took to complete the level. We also want to show their final score obtained. Then, our ranking system will be determined based on their final score and final time. If they took less than 1 minute and got 10000 points on their score, they get the “Master” ranking. This ranking system is not similar to the Donkey Kong system at all — which uses a pacing system. You can read more about that here.
Figure 11: Our Game’s Ranking System
In our Global script, let’s create three new variables that will store our final score, final rating, and final time.
### Global.gd
#older code
#final level results
var final_score
var final_rating
var final_time
In our LevelDoor script, we will display the values of these global variables in our UI Value nodes. These won’t show anything but “0” yet since we haven’t created a function that will capture the values of these variables yet.
### LevelDoor.gd
#older code
func _on_body_entered(body):
if body.name == "Player":
# pause game
get_tree().paused = true
# show menu
$UI/Menu.visible = true
# make modular value visible
$AnimationPlayer.play("ui_visibility")
#hide the player's UI
body.get_node("UI").visible = false
# show player values
$UI/Menu/Container/TimeCompleted/Value.text = str(Global.final_time)
$UI/Menu/Container/Score/Value.text = str(Global.final_score)
$UI/Menu/Container/Ranking/Value.text = str(Global.final_rating)
To get our final score, we will store the value of our player’s score value in a new function which will return the score, ranking, and time.
### Player.gd
#older code
func final_score_time_and_rating():
# Final results
Global.final_score = score
To get the time taken, we first need to create a new variable that will capture the time of the system when we started the game. We can get the time using the Time object, which has a method called get_ticks_msec(). This method returns the amount of time passed in milliseconds since the engine started.
### Player.gd
#older code
#time we started the level
var level_start_time = Time.get_ticks_msec()
In our final_score_time_and_rating() function, we will use the Time method again to get the time when the level ended, which we will deduct from our time started to get the completion time. From this completion time value, we will time it by 1000 to convert it into a seconds value.
### Player.gd
#older code
func final_score_time_and_rating():
# Time to complete in seconds
var time_taken = (Time.get_ticks_msec() - level_start_time) / 1000.0 # Convert to seconds
# Final results
Global.final_score = score
Since we don’t want the time to show with multiple decimals (such as 4.123974918371), we’ll round this value before storing it as our final time value.
### Player.gd
#older code
func final_score_time_and_rating():
# Time to complete in seconds
var time_taken = (Time.get_ticks_msec() - level_start_time) / 1000.0 # Convert to seconds
var time_rounded = str(roundf(time_taken)) + " secs"
# Final results
Global.final_score = score
Global.final_time = time_rounded
To get the ranking, we will create a temporary variable that will return a string value based on the time and score captured. The image below gives a good demonstration of how our ranking system works. You can change these values to whichever amounts you want, but just make sure that you give your player enough opportunities (such as score and attack boost pickups, and obstacles to jump over) to get x amount of points for n rating!
### Player.gd
#older code
func final_score_time_and_rating():
# Time to complete in seconds
var time_taken = (Time.get_ticks_msec() - level_start_time) / 1000.0 # Convert to seconds
var time_rounded = str(roundf(time_taken)) + " secs"
# Rating based on time and score
var rating = ""
if time_taken <= 60 and score >= 10000:
rating = "Master" # Exceptionally high score and fast completion
elif time_taken <= 120 and score >= 5000:
rating = "Pro" # Very high score and fast completion
elif time_taken <= 180 and score >= 3000:
rating = "Expert" # High score and moderately fast completion
elif time_taken <= 240 and score >= 2000:
rating = "Intermediate" # Good score and completion time
elif time_taken <= 300 and score >= 1000:
rating = "Amateur" # Decent score, but not very fast
else:
rating = "Beginner" # All other cases
# Final results
Global.final_score = score
Global.final_time = time_rounded
Global.final_rating = rating
Now, if we were to run our scene, our final values will still display as “0” since we haven’t called our final_score_time_and_rating() function in our LevelDoor scene yet — hence no values have been captured!
### LevelDoor.gd
#older code
func _on_body_entered(body):
if body.name == "Player":
# pause game
get_tree().paused = true
# show menu
$UI/Menu.visible = true
# make modular value visible
$AnimationPlayer.play("ui_visibility")
#hide the player's UI
body.get_node("UI").visible = false
#get final values
body.final_score_time_and_rating()
# show player values
$UI/Menu/Container/TimeCompleted/Value.text = str(Global.final_time)
$UI/Menu/Container/Score/Value.text = str(Global.final_score)
$UI/Menu/Container/Ranking/Value.text = str(Global.final_rating)
Your code should look like this.
Now if you run your scene and you make it to the top, your score, time, and ranking value should show! You should also be allowed to restart your game and continue to the next level. Please note that I cheated my score value in the code itself for testing purposes, so I am in no way a “Master” ranking player!
It’s easy to progress to the next level and get x amount of score points if your player never dies. Currently, our player’s lives can be depleted and we can still go ahead and gain score points. We need to stop this tomfoolery by giving our players a Death/Game Over screen! In the next part, we’ll implement this feature to stop our game if our player’s health runs out, which will force our player to replay the current level.
Since this part is running quite long already, we will break off here and continue in the next part where we create the UI items to show our Level value and time count on our screen. This way the player knows which level they are in, and they know what their time value is currently at.
Now would be a good time to save your project and make a backup of your project so that you can revert to this part if any game-breaking errors occur. Go back and revise what you’ve learned before you continue with the series, and once you’re ready, I’ll see you in the next part!
Next Part to the Tutorial Series
The tutorial series has 24 chapters. I’ll be posting all of the chapters in sectional daily parts over the next couple of weeks. You can find the updated list of the tutorial links for all 24 parts of this series on my GitBook. If you don’t see a link added to a part yet, then that means that it hasn’t been posted yet. Also, if there are any future updates to the series, my GitBook would be the place where you can keep up-to-date with everything!
Support the Series & Gain Early Access!
If you like this series and would like to support me, you could donate any amount to my KoFi shop or you could purchase the offline PDF that has the entire series in one on-the-go booklet!
The booklet gives you lifelong access to the full, offline version of the “Learn Godot 4 by Making a 2D Platformer” PDF booklet. This is a 451-page document that contains all the tutorials of this series in a sequenced format, plus you get dedicated help from me if you ever get stuck or need advice. This means you don’t have to wait for me to release the next part of the tutorial series on Dev.to or Medium. You can just move on and continue the tutorial at your own pace — anytime and anywhere!
Posted on August 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
July 8, 2023