DEV Community

Cover image for Let’s Learn Godot 4 by Making an RPG — Part 12: Player Shooting & Dealing Damage 🤠
christine
christine

Posted on • Updated on

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.

Godot RPG

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.

Godot RPG

We also need to see our node/bullet. Our bullet will have an impact animation, so we need to add an AnimatedSprite2D node.

Godot RPG

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.

Godot RPG

Change the FPS value to 10 and turn its looping value to off.

Godot RPG

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.

Godot RPG

Finally, add a script to your scene and save it under your Scripts folder.

Godot RPG

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG

    #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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG

    ### 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)
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG

    ### Bullet.gd

    # older code

    # ---------------- Bullet -------------------------
    # older code

    # Self-destruct
    func _on_timer_timeout():
        animated_sprite.play("impact")
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!

Godot RPG

In your Bullet scene, with your AnimatedSprite2D node selected, change its scale value from 1 to 0.4 underneath its Transform property.

Godot RPG

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!

Godot RPG

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.

Godot RPG

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG


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.

Godot RPG

To add an animation, click on the “Animation” label and say new. Let’s call this new animation “damage”.

Godot RPG

Godot RPG

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.

Figure 14: Animations Panel Overview

Figure 14: Animations Panel Overview

Godot RPG

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.

Godot RPG

Connect this track to your AnimatedSprite2D node, since this is the node you want to animate.

Godot RPG

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.

Godot RPG

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”.

Godot RPG

Godot RPG

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”.

Godot RPG

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).

Godot RPG

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.

Godot RPG


We need to change our track rate from continuous to discrete. It should look like this:

Godot RPG

Now if you run this animation, it should briefly change your enemy sprite’s color to red and then back to its default color.

Godot RPG

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
Enter fullscreen mode Exit fullscreen mode

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.


Godot RPG

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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.

Godot RPG

    ### 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)
Enter fullscreen mode Exit fullscreen mode

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!

Godot RPG

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.

Buy Me a Coffee at ko-fi.com


FULL TUTORIAL

Godot RPG

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 (5)

Collapse
 
mxglt profile image
Maxime Guilbert

You should create a serie for this tutorial, it would be easier to follow it and find all the other directly in dev ^^

Collapse
 
christinec_dev profile image
christine

I never knew you could create series in Dev.to! Thanks for the tip, I created one!😊

Collapse
 
red_ward_studio profile image
Red Ward Studio

I came across a problem in Godot 4.1 - the enemies spawned by EnemySpawner (aside from the first one) weren't being named "Enemy", but instead something like @CharacterBody2D@11 which was causing the bullet to not detect the enemy in _on_body_entered.

I hacked a solution in EnemySpawner.cs:

#enemy count, scene ref, and randomiser for enemy spawn position
var enemy_count = 0
var total_enemies = 0

# blah blah

func spawn_enemy():
    var enemy = enemy_scene.instantiate()
    add_child(enemy)
    enemy.name = "Enemy"+str(total_enemies)
    total_enemies += 1
    #print(enemy.name)  
    enemy.death.connect(_on_enemy_death)

#blah blah
Enter fullscreen mode Exit fullscreen mode

Do you know if this is a Godot 4.1 thing?

Collapse
 
alex2025 profile image
Alex

For anyone that ran into the red highlight for timer_node like I did, what is missing from the tutorial is the line at the top of the script: @onready var timer_node = $Timer.

Collapse
 
araime profile image
Nick Timofeev

hits are not counted and the animation does not work =(