Now that we have a basic enemy and player, we need to give ourselves a motive to kill the enemies. We can do this via a leveling system that increases our player’s level after a certain amount of XP is gained. If the player levels up, they get rewarded with pickups, and a stats (health, stamina) refill. We will also increase their max health and max stamina values upon a level-up.
WHAT YOU WILL LEARN IN THIS PART:
· How to work with Popup nodes.
· How to pause the scene tree.
· How to allow/disallow input processing.
· How to change a node’s Processing Mode.
· How to (optionally) change a mouse cursors image and visibility.
Leveling Overview
LEVEL UP POPUP
Once you’re ready, open up your game project, and in your Player script by your signals, let’s define three new signals that will update our xp, xp requirements, and level values.
### Player.gd
# Custom signals
signal health_updated
signal stamina_updated
signal ammo_pickups_updated
signal health_pickups_updated
signal stamina_pickups_updated
signal xp_updated
signal level_updated
signal xp_requirements_updated
Next, we will need to create the variables that these signals will update when they are emitted. You can change the xp, level, and required xp values that the player will start with to be any value you want.
### Player.gd
# XP and levelling
var xp = 0
var level = 1
var xp_requirements = 100
If you remembered how we updated our health and stamina GUI elements in the previous parts, you will know that we will have to create functions in our XP and Level elements in our Player scene, and then connect them to our signals in our Player scene.
In your Player Scene, underneath your UI CanvasLayer, attach a new script to your XP and Level ColorRect. Make sure to save this underneath your GUI folder.
Our XP ColorRect should also have two values, one for our XP and one for our XP Requirements, so go ahead and duplicate your Value node. It’s transform values can be seen in the images below.
We want to update the Value child nodes from these ColorRects (not the Label), so let’s go ahead and create a function for each new script to update our XP and Level values.
### XPAmount.gd
extends ColorRect
# Node refs
@onready var value = $Value
@onready var value2 = $Value2
#return xp
func update_xp_ui(xp):
#return something like 0
value.text = str(xp)
#return xp_requirements
func update_xp_requirements_ui(xp_requirements):
#return something like / 100
### LevelAmount.gd
extends ColorRect
# Node refs
@onready var value = $Value
# Return level
func update_level_ui(level):
#return something like 0
value.text = str(level)
Now we just have to connect these UI element functions to each of our newly created signals in our Player script.
### Player.gd
extends CharacterBody2D
# Node references
@onready var animation_sprite = $AnimatedSprite2D
@onready var health_bar = $UI/HealthBar
@onready var stamina_bar = $UI/StaminaBar
@onready var ammo_amount = $UI/AmmoAmount
@onready var stamina_amount = $UI/StaminaAmount
@onready var health_amount = $UI/HealthAmount
@onready var xp_amount = $UI/XP
@onready var level_amount = $UI/Level
@onready var animation_player = $AnimationPlayer
func _ready():
# Connect the signals to the UI components' functions
health_updated.connect(health_bar.update_health_ui)
stamina_updated.connect(stamina_bar.update_stamina_ui)
ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
health_pickups_updated.connect(health_amount.update_health_pickup_ui)
stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
xp_updated.connect(xp_amount.update_xp_ui)
xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
level_updated.connect(level_amount.update_level_ui)
We want to update our xp amount when we’ve killed an enemy, so to do that we need to create a new function in our Player script that will update our xp value and that emits our xp_updated signal.
### Player.gd
# older code
# ----------------- Level & XP ------------------------------
#updates player xp
func update_xp(value):
xp += value
#emit signals
xp_requirements_updated.emit(xp_requirements)
xp_updated.emit(xp)
level_updated.emit(level)
This function can then be called anywhere we want to update our xp, such as in our add_pickups() function or our Enemy’s death conditional in their damage() function.
### Player.gd
# older code
# ---------------------- Consumables ------------------------------------------
# Add the pickup to our GUI-based inventory
func add_pickup(item):
if item == Global.Pickups.AMMO:
ammo_pickup = ammo_pickup + 3 # + 3 bullets
ammo_pickups_updated.emit(ammo_pickup)
print("ammo val:" + str(ammo_pickup))
if item == Global.Pickups.HEALTH:
health_pickup = health_pickup + 1 # + 1 health drink
health_pickups_updated.emit(health_pickup)
print("health val:" + str(health_pickup))
if item == Global.Pickups.STAMINA:
stamina_pickup = stamina_pickup + 1 # + 1 stamina drink
stamina_pickups_updated.emit(stamina_pickup)
print("stamina val:" + str(stamina_pickup))
update_xp(5)
### Enemy.gd
# older code
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
animation_player.play("damage")
else:
#death
#stop movement
timer_node.stop()
direction = Vector2.ZERO
#stop health regeneration
set_process(false)
#trigger animation finished signal
is_attacking = true
#Finally, we play the death animation and emit the signal for the spawner.
animation_sprite.play("death")
#add xp values
player.update_xp(70)
death.emit()
#drop loot randomly at a 90% chance
if rng.randf() < 0.9:
var pickup = Global.pickups_scene.instantiate()
pickup.item = rng.randi() % 3 #we have three pickups in our enum
get_tree().root.get_node("Main/PickupSpawner/SpawnedPickups").call_deferred("add_child", pickup)
pickup.position = position
We’ll build on this function more throughout this part, as we still need to update our *xp_requirements *and run the check to see if our player has gained enough xp to level up. If they’ve gained enough xp, we need to reset our current xp amount back to zero and increase our player’s level and required xp values. Then we need to emit our signals to notify our game of the changes in these values.
### Player.gd
# older code
# ----------------- Level & XP ------------------------------
#updates player xp
func update_xp(value):
xp += value
#check if player leveled up after reaching xp requirements
if xp >= xp_requirements:
#reset xp to 0
xp = 0
#increase the level and xp requirements
level += 1
xp_requirements *= 2
#emit signals
xp_requirements_updated.emit(xp_requirements)
xp_updated.emit(xp)
level_updated.emit(level)
If you run your scene now and you kill some enemies (make sure you change their damage value to zero so that they can’t kill you during this test), you will see that your xp and level values update.
If our player levels up, we want a screen to show with a notification that our player has leveled up, plus a summary of the rewards they’ve gained from doing so. We will accomplish this by adding a CanvasLayer node to our Player’s UI node. In your Player scene, add a new CanvasLayer node to your UI layer. Rename it to LevelUpPopup.
In this CanvasLayer node, add two ColorRects and a Button node. The first ColorRect will contain our level-up label, and the second ColorRect will contain the summary of our rewards. The button will allow the player to confirm the notification and continue with their game. You can rename them as follows:
Change your Message node’s color to #d6c376, and its size (x: 142, y: 142); position (x: 4, y: 4); anchor_preset (center).
Drag your Rewards node into your Message node to turn it into a child of that node. Change its color to #365655, and its size (x: 100, y: 75); position (x: 20, y: 30); anchor_preset (center).
Also, drag your Confirm node into your Message node to turn it into a child of that node. Change its color to #365655, and its size (x: 100, y: 75); position (x: 20, y: 30); anchor_preset (center). Change its font to “Schrodinger” and its text to “CONTINUE”.
Now, in your Message node, at the top, let’s add a new Label node to display our welcome text “Level Up!”. Change its font to “Arcade Classic” and its font size to 15. Then change its anchor_preset (center-top); horizontal alignment (center); vertical alignment (center); and position (y: 5).
In your Rewards node, add six new Label nodes.
Rename them as follows:
Change their properties as follows:
All of them:
Text = “1”
Font = “Schrödinger”
Font-size = 10
Anchor Preset = center-top
Horizontal Alignment = center
Vertical Alignment = center
LevelGained:
- Position= y: 0
HealthIncreaseGained:
- Position= y: 10
StaminaIncreaseGained:
- Position= y: 20
HealthPickupsGained:
- Position= y: 30
StaminaPickupsGained:
- Position= y: 40
AmmoPickupsGained:
- Position= y: 50
With our Popup created, we can go ahead and hide our popup for now. You can do this in the Inspector panel underneath Canvas Item > Visibility, or just click the eye icon next to the node to hide it.
We need to go back to our update_xp() function in our Player scene to update our conditional that checks if the player levels up. If they are leveling up, we need to pause the game, display the popup with all of the reward values and only hide the popup if the player clicks the confirm button. In this function we will have to up the player’s max health and stamina, as well as give them some ammo, and health and stamina drinks. After we’ve done this, we’ll need to reflect these changes in our UI elements.
If we want to pause the game, we simply use the SceneTree.paused method. If the game is paused, no input from the player or us will be accepted, because everything is paused. That is unless we change our node’s process mode. Each node in Godot has a “Process Mode” that defines when it processes. It 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 LevelUpPopup node to only work when the game is paused. This will allow us to click the Confirm button to unpause the game, thus allowing the other nodes to continue processing since they all only work or process input if the game is in an unpaused state. Let’s change our LevelUpPopup’s Process Mode to WhenPaused. You can find this option under Node > Process > Mode.
Because we changed the LevelUpPopup node’s process mode, all of its children will also inherit that processing mode, so all of them will work when the game is paused. Before we pause our game in our code, we’ll also need to first allow input to be processed via the set_process_input method. This method enables or disables input processing. Then we will increase our health, stamina, xp, level and pickup values and reflect these changes on our UI! Let’s make these changes in our code.
### Player.gd
extends CharacterBody2D
# Node references
@onready var animation_sprite = $AnimatedSprite2D
@onready var health_bar = $UI/HealthBar
@onready var stamina_bar = $UI/StaminaBar
@onready var ammo_amount = $UI/AmmoAmount
@onready var stamina_amount = $UI/StaminaAmount
@onready var health_amount = $UI/HealthAmount
@onready var xp_amount = $UI/XP
@onready var level_amount = $UI/Level
@onready var animation_player = $AnimationPlayer
@onready var level_popup = $UI/LevelPopup
# ----------------- Level & XP ------------------------------
#updates player xp
func update_xp(value):
xp += value
#check if player leveled up after reaching xp requirements
if xp >= xp_requirements:
#allows input
set_process_input(true)
#pause the game
get_tree().paused = true
#make popup visible
level_popup.visible = true
#reset xp to 0
xp = 0
#increase the level and xp requirements
level += 1
xp_requirements *= 2
#update their max health and stamina
max_health += 10
max_stamina += 10
#give the player some ammo and pickups
ammo_pickup += 10
health_pickup += 5
stamina_pickup += 3
#update signals for Label values
health_updated.emit(health, max_health)
stamina_updated.emit(stamina, max_stamina)
ammo_pickups_updated.emit(ammo_pickup)
health_pickups_updated.emit(health_pickup)
stamina_pickups_updated.emit(stamina_pickup)
xp_updated.emit(xp)
level_updated.emit(level)
#reflect changes in Label
$UI/LevelPopup/Message/Rewards/LevelGained.text = "LVL: " + str(level)
$UI/LevelPopup/Message/Rewards/HealthIncreaseGained.text = "+ MAX HP: " + str(max_health)
$UI/LevelPopup/Message/Rewards/StaminaIncreaseGained.text = "+ MAX SP: " + str(max_stamina)
$UI/LevelPopup/Message/Rewards/HealthPickupsGained.text = "+ HEALTH: 5"
$UI/LevelPopup/Message/Rewards/StaminaPickupsGained.text = "+ STAMINA: 3"
$UI/LevelPopup/Message/Rewards/AmmoPickupsGained.text = "+ AMMO: 10"
#emit signals
xp_requirements_updated.emit(xp_requirements)
xp_updated.emit(xp)
level_updated.emit(level)
Finally, we need to give our Confirm button the ability to close the popup and unpause our game. We can do this by connecting its pressed() signal to our Player script.
In this newly created _on_confirm_pressed(): function, we will simply hide the popup again and unpause the game.
### Player.gd
# older code
# close popup
func _on_confirm_pressed():
level_popup.visible = false
get_tree().paused = false
Now if we run our scene and we shoot enough enemies, we will see the popup appear with our rewards values, and if we click on the confirm button, our popup closes, and we can continue the game with our new values!
SHOWING & HIDING CURSOR
I don’t like the way our cursor always shows. Whether or not the game is paused, our cursor can always be found lingering on our screen. Since this is not a point-and-click game, there is no reason for our cursor to show when our game is not paused, since we’ll spend our time running around and shooting enemies. Our cursor should hence only show if we are in a menu screen such as a pause or main menu, or even during our dialog screen — i.e. when the game is paused and we need the cursor for input.
Luckily, this is a quick fix. We can change the visibility of our mouse’s cursor via our Input singletons MouseMode method. In our Player’s script we will show the cursor whenever game is paused, and if it is not paused then we will hide the cursor.
### Player.gd
# ----------------- Level & XP ------------------------------
#updates player xp
func update_xp(value):
xp += value
#check if player leveled up after reaching xp requirements
if xp >= xp_requirements:
#allows input
set_process_input(true)
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
#pause the game
#emit signals
xp_requirements_updated.emit(xp_requirements)
xp_updated.emit(xp)
level_updated.emit(level)
# close popup
func _on_confirm_pressed():
level_popup.visible = false
get_tree().paused = false
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
We also need to hide our cursor on load.
### Player.gd
func _ready():
# Connect the signals to the UI components' functions
health_updated.connect(health_bar.update_health_ui)
stamina_updated.connect(stamina_bar.update_stamina_ui)
ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
health_pickups_updated.connect(health_amount.update_health_pickup_ui)
stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
xp_updated.connect(xp_amount.update_xp_ui)
xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
level_updated.connect(level_amount.update_level_ui)
# Reset color
animation_sprite.modulate = Color(1,1,1,1)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
We can also change our cursor’s image. If you go into your Project Settings > Display > Mouse Cursor, you can change your mouse cursor’s image.
You can find free mouse cursor packs here. I used the Free Basic Cursor Pack from VOiD1 Gaming.
Now if you run your game, your cursor should be hidden/shown when your pause state changes.
SHOWING VALUES ON LOAD
If we change the values of our variables and we run our game, you might’ve noticed that your Level, XP, and Pickup values aren’t updating. We need to fix this by going into our UI scripts and calling our values from our Player script on load.
### HealthAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
@onready var player = $"../.."
# Show correct value on load
func _ready():
value.text = str(player.health_pickup)
# Update ui
func update_health_pickup_ui(health_pickup):
value.text = str(health_pickup)
### StaminaAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
@onready var player = $"../.."
# Show correct value on load
func _ready():
value.text = str(player.stamina_pickup)
# Update ui
func update_stamina_pickup_ui(stamina_pickup):
value.text = str(stamina_pickup)
### LevelAmount.gd
extends ColorRect
# Node refs
@onready var value = $Value
@onready var player = $"../.."
# On load
func _ready():
value.text = str(player.level)
# Return level
func update_level_ui(level):
#return something like 0
value.text = str(level)
### XPAmount.gd
extends ColorRect
# Node refs
@onready var value = $Value
@onready var value2 = $Value2
@onready var player = $"../.."
# On load
func _ready():
value.text = str(player.xp)
value2.text = "/" + str(player.xp_requirements)
#return xp
func update_xp_ui(xp):
#return something like 0
value.text = str(xp)
#return xp_requirements
func update_xp_requirements_ui(xp_requirements):
#return something like / 100
value2.text = "/" + str(xp_requirements)
### AmmoAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
@onready var player = $"../.."
# Show correct value on load
func _ready():
value.text = str(player.ammo_pickup)
# Update ui
func update_ammo_pickup_ui(ammo_pickup):
value.text = str(ammo_pickup)
Now if you run your scene, your values should show correctly, which will be useful when we load our game later on!
Congratulations, your player can now get rewarded for killing bad guys. We’re still not 100% done with this, for in the next part we will add a basic NPC and quest that will also reward our player with XP upon completing this quest. Remember to save your project, and I’ll see you in the next part.
The final source code for this part should look like this.
FULL TUTORIAL
The tutorial series has 23 chapters. I’ll be releasing all of the chapters in sectional daily parts over the next couple of weeks.
If you like this series or want to skip the wait and access the offline, full version of the tutorial series, you can support me by buying the offline booklet for just $4 on Ko-fi!😊
You can find the updated list of the tutorial links for all 23 parts in this series here.
Top comments (4)
I noticed that
valid_spawn_location(position)
in EnemySpawner doesn't use theposition
passed in. Calculatingtilemap.get_layer_name(1) || tilemap.get_layer_name(2)
always returnstrue
... I tried solving this problem, no progress so far. Any advice?We don't use the position value in the parameters in the function itself, but we do call this value when we check the enemies' location for a valid position {valid_location = valid_spawn_location(enemy.position)}.
Our function checks if the layers for sand and grass exist in the tilemap, which will always be true if those layers are defined in your tilemap. However, now that I'm looking at my original logic, I see that I could improve on this - since we want to only return true if the position is on a sand or grass tile, and false otherwise.
Replace the existing function with this:
Let me know if this works?
Yes this now works! I saw these functions in the documentation (local_to_map, get_cell_source_id) but didn't know how to put it together.
Thank you!!
*Please note that popup nodes close by default if you press the ESC key or if they click outside of the popup box. If you don’t like this, you can simply change the Instruction node’s type to be a CanvasLayer, and then instead of .show() use $node.visible = true, and .hide() should change to $node.visible = false.