Let’s Learn Godot 4 by Making an RPG — Part 12: Player Shooting & Dealing Damage 🤠
What good is an enemy if you can’t shoot them? In this part, we’re going create bullets that our player can shoot in the direction that they’re facing. Then we’re going to add an animation that will show that our enemy is being damaged if our bullet hits them. If the bullets hit them enough for their health value to run out, they will also die. Later on, we will up our player’s XP and add loot to the enemy on their death. But for now, let’s buckle up because this part is going to be a long one!
WHAT YOU WILL LEARN IN THIS PART:
· How to use the AnimationPlayer node.
· How to use the RayCast2D node.
· How to work with modulate values.
· How to work with the Time class.
SPAWNABLE BULLET
Let’s start by creating the bullets that will spawn when our player shoots or attacks. This scene will be similar to our Pickups scene’s structure. Create a new scene with an Area2D node as its root. Rename this node as Bullet and save it under your Scenes folder.
It has a warning message because it has no shape. Let’s fix this by adding a CollisionShape2D node to it with a RectangleShape2D as its shape.
We also need to see our node/bullet. Our bullet will have an impact animation, so we need to add an AnimatedSprite2D node.
Add a new SpriteFrames resource to it in the Inspector panel, and in your SpriteFrames pane below, let’s add a new animation called “impact”. The spritesheet we will use for our bullet can be found under Assets > FX > Death Explosion.png.
Horizontally, we count 8 frames, and vertically we count 1 frame, so let’s change our values accordingly to crop out our animation frames. Also, select only the first three frames for your animation.
Change the FPS value to 10 and turn its looping value to off.
We also need to add a Timer node to our scene with autoplay enabled. This timer will emit its timeout() signal when the bullet needs to “self-destruct” after it’s not hit or impacted anything.
Finally, add a script to your scene and save it under your Scripts folder.
Okay, now we can start talking about what our Bullet scene needs to do. We first need to define a few variables which will set the bullet’s speed, direction, and damage. We also need to reference our Tilemap node again so that we can ignore the collision with certain layers, such as our water, which has collisions added to it, but it shouldn’t stop the bullet.
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
Then we need to calculate the position of our bullet after it’s been shot. This position should be re-calculated for each frame in case our player moves. Therefore we can calculate it in our built-in process() function so that it updates its direction during each frame movement. We can calculate this position by adding its current position by its direction times its speed at the current frame captured as delta.
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
When we did our Pickups, we connected the Area2D’s body_entered() signal to our scene to add the pickups to our player’s inventory. We want to do the same for our Bullet scene, but this time we will check the body entered is our enemy so that we can injure them. We also want to check if the body entering our Bullets collision is the Player or “Water” layer from our TileMap because we want to ignore these collisions.
Connect the body_entered() signal to your Bullet script. You will see that it creates a new ‘func _on_body_entered(body):’ function at the end of your script. Inside this new function, let’s accomplish the objectives we set above.
#Collision detection for the bullet
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(0):
return
# If the bullets hit an enemy, damage them
if body.name.find("Enemy") >= 0:
#todo: add damage/hit function to enemy scene
pass
We cannot damage the enemy yet because they do not have any health variables or functions set up. We will do that in a minute, but for now, let’s stop the bullet from moving and change its animation to “impact” if it does not hit anything. We’re doing this because we don’t want a random bullet just floating around in our scene.
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
if body.name.find("Enemy") >= 0:
#todo: add damage/hit function to enemy scene
pass
We cannot damage the enemy yet because they do not have any health variables or functions set up. We will do that in a minute, but for now, let’s stop the bullet from moving and change its animation to “impact” if it does not hit anything. We’re doing this because we don’t want a random bullet just floating around in our scene.
### Bullet.gd
extends Area2D
# Node refs
@onready var tilemap = get_tree().root.get_node("Main/Map")
@onready var animated_sprite = $AnimatedSprite2D
# older code
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
#todo: add damage/hit function to enemy scene
# Stop the movement and explode
direction = Vector2.ZERO
animated_sprite.play("impact")
We also need to delete the bullet from the scene after it stopped moving and stopped playing the “impact” animation. We’ve done this before in our Pickups scene, so let’s go ahead and connect our AnimationSprite2D animation_finished signal to our Bullet script.
### Bullet.gd
# older code
# ---------------- Bullet -------------------------
# older code
# Remove
func _on_animated_sprite_2d_animation_finished():
if animated_sprite.animation == "impact":
get_tree().queue_delete(self)
Finally, in your Bullet scene, connect your Timer node’s timeout() signal to your Bullet script. We want this function to also play the “impact” animation after it’s not hit anything after two seconds because playing this animation will trigger the animation_finished signal, which will delete the bullet from our scene.
### Bullet.gd
# older code
# ---------------- Bullet -------------------------
# older code
# Self-destruct
func _on_timer_timeout():
animated_sprite.play("impact")
Don’t forget to change your Timer node’s wait time to 2 so that it plays the animation after 2 seconds and not 1.
PLAYER SHOOTING
For now, we are done with our Bullet scene since we have our bullet movement and animation setup, plus the ability to remove the bullet from a scene. We need to now go back to our Player scene so that we can fire off bullets via our ui_attack input.
Let’s preload our Bullet script in our Global script.
### Global.gd
extends Node
# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
@onready var enemy_scene = preload("res://Scenes/Enemy.tscn")
@onready var bullet_scene = preload("res://Scenes/Bullet.tscn")
In your Player script, let’s define some variables that would set the bullet damage, reload time, and the bullet fired time.
### Player.gd
# older code
# Bullet & attack variables
var bullet_damage = 30
var bullet_reload_time = 1000
var bullet_fired_time = 0.5
Now we can change our ui_attack input to spawn bullets, calculate the reload, and remove ammo. When we spawn these bullets, we need to take into consideration the time the bullet was fired vs. the reload time. We want our player to take a 1000-ms break before being able to fire off the next round. To get the time in Godot, we can use the Time object. The Time singleton allows converting time between various formats and also getting time information from the system. We will use the method .get_ticks_msec() for precise time calculation.
First, we will check if our current time is bigger or equal to our bullet_fired_time AND if we have ammo to shoot (make sure you assign some ammo to your player first).
If it is bigger, that means we can fire off a round. We will then return our is_attacking boolean as true and play our shooting animation. We will update our bullet_fired_time by adding our reload_time to our current time. This means our player will have a 1000-ms pause before they fire off the next round. Finally, we will update our ammo pickup amount.
### Player.gd
# older code
func _input(event):
#input event for our attacking, i.e. our shooting
if event.is_action_pressed("ui_attack"):
#checks the current time as the amount of time passed in milliseconds since the engine started
var now = Time.get_ticks_msec()
#check if player can shoot if the reload time has passed and we have ammo
if now >= bullet_fired_time and ammo_pickup > 0:
#shooting anim
is_attacking = true
var animation = "attack_" + returned_direction(new_direction)
animation_sprite.play(animation)
#bullet fired time to current time
bullet_fired_time = now + bullet_reload_time
#reduce and signal ammo change
ammo_pickup = ammo_pickup - 1
ammo_pickups_updated.emit(ammo_pickup)
# older code
We will spawn our bullet in our _on_animated_sprite_2d_animation_finished function, because only after the shooting animation has played do we want our bullet to be added to our Main scene.
We’ll have to create another instance of our Bullet scene, where we will update its damage, direction, and position as the direction the player was facing when they fired off the round, and the position 4–5 pixels in front of the player (you don’t want the bullet to come from inside of your player, but instead from the gun’s “barrel”).
##
### Player.gd
# older code
# Reset Animation states
func _on_animated_sprite_2d_animation_finished():
is_attacking = false
# Instantiate Bullet
if animation_sprite.animation.begins_with("attack_"):
var bullet = Global.bullet_scene.instantiate()
bullet.damage = bullet_damage
bullet.direction = new_direction.normalized()
# Place it 4-5 pixels away in front of the player to simulate it coming from the guns barrel
bullet.position = position + new_direction.normalized() * 4
get_tree().root.get_node("Main").add_child(bullet)
Now if you run your scene, a bullet should spawn after you press CTRL to fire off a bullet (ui_attack input action). It’s a bit big for a bullet, so let’s change its size!
In your Bullet scene, with your AnimatedSprite2D node selected, change its scale value from 1 to 0.4 underneath its Transform property.
Also, add that fourth frame of the bullet FX to your “impact” animation. Sorry for the inconvenience of adding it now, I just thought it looked better at the last minute!
Now if you run your scene, your bullet should be much smaller. It should also self-destruct after 2 seconds, or when it hits another collision body. Now we need to add the functionality for our enemy to be damaged by a bullet impact.
ENEMY DAMAGE
Back in our Bullet script, I added a #todo since we still do not have a damage function or health variables set up in our Enemy scene. We will do that now.
In your Enemy script, let’s set up its health variables. Just like the player, we need variables to store its health, max health, and health regeneration, as well as a signal to fire off when it dies.
### Enemy.gd
extends CharacterBody2D
# Node refs
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
# Enemy stats
@export var speed = 50
var direction : Vector2 # current direction
var new_direction = Vector2(0,1) # next direction
var animation
var is_attacking = false
var health = 100
var max_health = 100
var health_regen = 1
# Direction timer
var rng = RandomNumberGenerator.new()
var timer = 0
# Custom signals
signal death
We will calculate its health regeneration in its _process(delta) function since we want to calculate it at each frame. We’ve already calculated this in the Player script when we did its updated_health value.
### Enemy.gd
extends CharacterBody2D
# older code
#------------------------------------ Damage & Health ---------------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
Let’s also go ahead and start creating our damage function. This will be the function that we call in our Player and Bullet scenes to damage the enemy upon bullet/attack impact. Before we do that, we need to also give the enemy some attack variables. We can just copy over the attack variables from our Player script.
### Enemy.gd
extends CharacterBody2D
# older code
# Bullet & attack variables
var bullet_damage = 30
var bullet_reload_time = 1000
var bullet_fired_time = 0.5
The damage function will decrease our Enemy’s health by the bullet damage passed in by the Player or attacker. If their health is more than zero, the enemy will be damaged. If it’s less than or equal to zero, the enemy will die.
### Enemy.gd
# older code
#------------------------------------ Damage & Health ---------------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
pass
else:
#death
pass
We will slowly make our way to completing our damage function, so let’s get started by adding an animation that would indicate that our Enemy has been hit. For this, we can change the enemy’s modulate value via the AnimationPlayer node. The modulate value refers to the node’s color, so in simple terms, we will use the AnimationPlayer to briefly turn our enemy’s color red so that we can see that they are damaged. You will use this AnimationPlayer node whenever you need to animate non-sprite items, such as labels or colors. Let’s add this node to our Enemy scene.
When to use AnimatedSprite2D node vs. AnimationPlayer node?
Use the AnimatedSprite2D node for simple, frame-by-frame 2D sprite animations. Use the AnimationPlayer for complex animations involving multiple properties, nodes, or additional features like audio and function calls.
We add animations to the AnimationPlayer node in the Animations panel at the bottom of the editor.
To add an animation, click on the “Animation” label and say new. Let’s call this new animation “damage”.
Your new animation will have a length of “1” assigned to it as you can see the values run from 0 to 1. Let’s change this length to 0.2 because we want this damage indicator to be very short. You can zoom in/out on your track by holding down CTRL and zooming with your mouse wheel.
We want this animation to briefly change our AnimatedSprite2D node’s modulate (color) value, which is a property that we can change in the node’s Inspector panel. Therefore, we need to add a new Property Track. Click on “+ Add Track” and select Property Track.
Connect this track to your AnimatedSprite2D node, since this is the node you want to animate.
Next, we need to choose the property of the node that we want to change. We want the modulate value, so choose it from the list.
Now that we have the property that we want to change, we need to insert keys to the track. These will be the animation keyframes, which define the starting and/or ending point of our animation. Let’s insert two keys: one on our 0 value (starting point), and one on our 0.2 value (ending point). To insert a key, just right-click on your track and select “Insert Key”.
If you click on the cubes or keys, the Inspector panel will open, and you will see that you can change the modulate value of our node at that frame underneath “Value”.
We want our modulate value to go from red at keyframe 0 to white at keyframe 1. To make our modulate value red, we can just change its RGB values to (255, 0, 0).
We also want to change the track rate of our animation. If you were to run the animation now, the modulate color would gradually change from red to white. Instead, we want it to change instantly from red to white when the 0.2 keyframe value is reached. To do this we can change its track rate, which is found next to our track under the curved white line.
Godot has three options for our track rate:
- Continuous: Update the property on each frame.
- Discrete: Only update the property on keyframes.
- Trigger: Only update the property on keyframes or triggers.
We need to change our track rate from continuous to discrete. It should look like this:
Now if you run this animation, it should briefly change your enemy sprite’s color to red and then back to its default color.
We can go back to our damage function and now play this animation if the enemy gets damaged.
### Enemy.gd
extends CharacterBody2D
# Node refs
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
@onready var animation_player = $AnimationPlayer
# older code
#------------------------------------ Damage & Health ---------------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
animation_player.play("damage")
else:
#death
pass
Now, to damage the enemy, we will have to add our enemy to a group. This will allow us to use our Area2D node in our Bullet scene to only damage our body if they belong to our “enemy” group. Click on your root node in your Enemy scene, and underneath the Groups property assign the enemy to the group “enemy”.
What are Groups?
Groups are a way to organize and manage nodes in a scene tree. They provide a convenient way for applying operations or logic to a set of nodes that share a common characteristic or purpose.
Let’s go back to our #todo in our Bullet script and replace it with the damage function that we just created.
### Bullets.gd
extends Area2D
# older code
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
if body.is_in_group("enemy"):
body.hit(damage)
# Stop the movement and explode
direction = Vector2.ZERO
animated_sprite.play("impact")
ENEMY DEATH
Lastly for this section, we need to add the functionality for our enemy to die. We already created our signal for this, now we just need to update our existing code in our Enemy script to play our death animation and remove the enemy from the scene tree, as well as update our Enemy Spawner code to connect to this signal and update our enemy count.
Let’s start in our Enemy script underneath our death conditional. When our enemy dies, the first thing we do is stop the timer that handles its movement and direction. We also need to stop our process() function from regenerating our enemy’s health value, so we will set the set_process value to false.
### 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")
death.emit()
We also need to set our is_attacking variable equal to true so that we can trigger our animation finished signal so that we can remove our node from our scene after our death animation plays. We simply want our enemy to stop, play its death animation, and then signal that it has died so that a new enemy can spawn.
### Enemy.gd
#------------------------------------ Damage & Health ---------------------------
# remove
func _on_animated_sprite_2d_animation_finished():
if animation_sprite.animation == "death":
get_tree().queue_delete(self)
is_attacking = false
Now we need to go to our EnemySpawner script to create a function that will remove a point from our enemy count after the death signal from our Enemy script has been emitted.
###EnemySpawner.gd
# --------------------------------- Spawning ------------------------------------
# older code
# Remove enemy
func _on_enemy_death():
enemy_count = enemy_count - 1
We want this function to be connected to our signal in our spawn function so that our spawner knows that an enemy has been removed and it should spawn a new one.
###EnemySpawner.gd
# --------------------------------- Spawning ------------------------------------
func spawn_enemy():
var attempts = 0
var max_attempts = 100 # Maximum number of attempts to find a valid spawn location
var spawned = false
while not spawned and attempts < max_attempts:
# Randomly select a position on the map
var random_position = Vector2(
rng.randi() % tilemap.get_used_rect().size.x,
rng.randi() % tilemap.get_used_rect().size.y
)
# Check if the position is a valid spawn location
if is_valid_spawn_location(Global.GRASS_LAYER, random_position) || is_valid_spawn_location(Global.SAND_LAYER, random_position):
var enemy = Global.enemy_scene.instantiate()
enemy.death.connect(_on_enemy_death) # add this
enemy.position = tilemap.map_to_local(random_position) + Vector2(16, 16) / 2
spawned_enemies.add_child(enemy)
spawned = true
else:
attempts += 1
if attempts == max_attempts:
print("Warning: Could not find a valid spawn location after", max_attempts, "attempts.")
Finally, back in our Enemy script, we’ll need to reset our Enemy’s modulate value after the damage animation has played as well as when they spawn. This will prevent our enemy from spawning or staying red after they’ve been damaged. Connect your AnimationPlayer node’s animation_finished signal to your Enemy script.
### Enemy.gd
# older code
func _ready():
rng.randomize()
# Reset color
animation_sprite.modulate = Color(1,1,1,1)
# Reset color
func _on_animation_player_animation_finished(anim_name):
animation_sprite.modulate = Color(1,1,1,1)
And so, our Player can now shoot a bullet and if it hits an enemy it damages them. After three hits, it kills the enemy and removes them from the scene!
And that’s it for this part. It was quite a lot of work, and if you made it this far, congratulations on being persistent! Next up we’re going to spawn loot upon our enemy’s death, and then we’ll add the functionality for them to attack our player character and deal damage to us! Remember to save your game project, and I’ll see you in the next part.
The final source code for this part should look like this.
TUTORIAL RELEASE
LINKS TO OTHER PARTS
The tutorial series has 23 chapters. I’ll be releasing 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 23 parts in this series here.
FULL TUTORIAL
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 $3 on Ko-fi!😊