Learn Godot 4 by Making a 2D Platformer — Part 14: Lives, Score, & Attack Boosts #2

christine - Aug 1 '23 - - Dev Community

*You can find the links to the previous parts at the bottom of this tutorial.

Previously we set up our Health pickup with our UI for it. In this part, we’ll do the same for our attack boost pickup. Our attack pickup will activate a timer that will allow our player to destroy bombs and boxes for a short amount of time. We’ll then also allow our player to use their attack animation only if the attack timer is active.


WHAT YOU WILL LEARN IN THIS PART:

  • How to connect custom signals.
  • How to work with RayCast2D nodes.

STEP 1: ATTACK TIMER COUNTDOWN

In our Player scene, let’s add a new Timer node. This will serve as our countdown timer for our attack boost. If the timer is active, it will allow our player to attack. If the timer runs out we will disable our player from attacking.

Godot 2D Platformer

Rename the timer to “AttackBoostTimer” and connect its timeout() signal to your script. We will use this timeout to reset our Global.is_attacking variable back to false. It will only do this if our timer’s wait_time reaches 0.

Godot 2D Platformer

Godot 2D Platformer

Our wait_time will be how long our attack boost is enabled in seconds. Change this value to something higher, such as 1000.

Godot 2D Platformer

Now in our Player.gd script, let’s define a variable that will hold our wait_time value. When our Global.is_attacking variable is true, we will countdown this wait_time to 0.

    ### Player.gd

    #older code 

    #seconds allowed to attack
    var attack_time_left = 0

    func _ready():
        current_direction = -1
        #set our attack timer to be the value of our wait_time
        attack_time_left =  $AttackBoostTimer.wait_time
Enter fullscreen mode Exit fullscreen mode

We’ll also create a new signal that will, later on, emit our attack_time_left variables value when it changes in our UI script for our Attack element (we still need to create this).

    ### Player.gd

    #older code 

    #custom signals
    signal update_lives(lives, max_lives)
    signal update_attack_boost(attack_time_left)
Enter fullscreen mode Exit fullscreen mode

Now in our on_attack_boost_timer_timeout() function, we will set our Global.is_attacking value back to false.

    ### Player.gd

    #older code 

    #attack boost timer timeout (when it reaches 0)
    func _on_attack_boost_timer_timeout():
        #sets attack back to false if the time on boost runs out
        if attack_time_left <= 0:
            Global.is_attacking = false
Enter fullscreen mode Exit fullscreen mode

This will stop our player from being able to hitboxes or play their animation for the ui_attack input. Let’s update our ui_attack input to only play our animation if Global.is_attacking is true.

    ### Player.gd

    #older code 

    #singular input captures
    func _input(event):
        #on attack
        if Input.is_action_just_pressed("ui_attack"):
            if Global.is_attacking == true:
                $AnimatedSprite2D.play("attack")
Enter fullscreen mode Exit fullscreen mode

We also need to update our _on_animated_sprite_2d_animation_finished() function to only reset our Global.is_attacking back to false if our attack_time_left value is equal to “0”. If we don’t do this, it will stop our timer before we reach 0 on our countdown since it will reset our value after our attack animation plays.

    ### Player.gd

    #older code 

    #reset our animation variables
    func _on_animated_sprite_2d_animation_finished():
        if attack_time_left <= 0:
            Global.is_attacking = false
        set_physics_process(true)
Enter fullscreen mode Exit fullscreen mode

We will set our Global.is_attacking value to true if our player picks up an Attack Boost pickup.

    ### Player.gd

    #older code 

    #adds pickups to our player and updates our lives/attack boosts
    func add_pickup(pickup):
        #increases life count if we don't have 3 lives already
        if pickup == Global.Pickups.HEALTH:
            if lives < max_lives:
                lives += 1
                update_lives.emit(lives, max_lives)

        #temporarily allows us to destroy boxes/bombs
        if pickup == Global.Pickups.ATTACK:
            Global.is_attacking = true

        #increases our player's score
        if pickup == Global.Pickups.SCORE:
            pass
Enter fullscreen mode Exit fullscreen mode

If our Global.is_attacking variable is true, it will start counting down until our timer reaches 0. If our timer reaches 0, our variable will be set back to false. We can update the value of our attack_time_left variable in our physics_process() function. This will emit the changes to our attack_time_left variable as it counts down 1 second every 1 second.

In the code below, if the player is attacking (Global.is_attacking is true), it subtracts 1 from the attack_time_left variable using the expression attack_time_left — 1. The “1” value is subtracted to ensure that the countdown progresses based on the actual elapsed time since the last frame. The result of the subtraction is clamped to a minimum value of 0 using max(0, attack_time_left — 1). This prevents the attack_time_left from becoming negative. If the subtraction result is less than 0, it sets attack_time_left to 0.

    ### Player.gd

    #older code 

    #movement and physics
    func _physics_process(delta):
        # vertical movement velocity (down)
        velocity.y += gravity * delta
        # horizontal movement processing (left, right)
        horizontal_movement()

        #applies movement
        move_and_slide() 

        #applies animations
        if Global.is_climbing == false:
            player_animations()

        #countdown for attack boost
        if Global.is_attacking == true:
            attack_time_left = max(0, attack_time_left - 1)
            update_attack_boost.emit(attack_time_left)
            print(attack_time_left)
Enter fullscreen mode Exit fullscreen mode

If you run your scene now and you run through an Attack Pickup, the timer should activate and start counting down until it reaches 0. You should also not be able to attack unless the timer is active.

Godot 2D Platformer

Godot 2D Platformer

STEP 2: DESTROYING BOXES & BOMBS

Attacking does not destroy the boxes or bombs yet. Since our player does not have an attached weapon that has its collisions set up for detection, we’ll have to improvise with a RayCast2D node. A RayCast represents a line from its origin to its destination position, target_position. It is used to query the 2D space to find the closest object along the path of the ray.

We will use this raycast to check if our raycast is hitting our Bomb or Box collisions. If it is, and we are pressing our attack input, then the object will be removed. In your Player scene, add a RayCast2D node.

Godot 2D Platformer

In the Inspector panel, change its target_positions to be (x: 50, y: 0). This is the length or the raycast in the x and y position. We will change its x value later on when we change it to be towards our current direction. Also, enable the option for it to Collide with Areas. This will allow us to trigger events if the RayCast collides with our Area2D nodes, which are the root nodes of our Box and Bomb scenes.

Godot 2D Platformer

Godot 2D Platformer

Let’s rename our Raycast to “AttackRayCast”.

Godot 2D Platformer

Now, in our physics_process function let’s delete our Box or Bomb if it collides with the player’s raycast. We will get the colliders via the RayCast2D node’s get_collider() method. This returns the first object that the ray intersects, or null if no object is intersecting the ray.

    ### Player

    #older code

    #movement and physics
    func _physics_process(delta):
        # vertical movement velocity (down)
        velocity.y += gravity * delta
        # horizontal movement processing (left, right)
        horizontal_movement()

        #applies movement
        move_and_slide() 

        #applies animations
        if Global.is_climbing == false:
            player_animations()

        #countdown for attack boost
        if Global.is_attacking == true:
            attack_time_left = max(0, attack_time_left - 1)
            update_attack_boost.emit(attack_time_left)
            #gets the colliders of our raycast
            var target = $AttackRayCast.get_collider()
            #is target valid
            if target != null:
                #remove box
                if target.name == "Box" and Input.is_action_pressed("ui_attack"):
                    Global.disable_spawning()
                    target.queue_free()
                #remove bomb
                if target.name == "Bomb" and Input.is_action_pressed("ui_attack"):         
                    Global.is_bomb_moving = false
Enter fullscreen mode Exit fullscreen mode

If we were to run our scene from this alone, our Raycast will not face the direction that we are facing. It will always only face x: 50 (right), so if we try to hit a box from behind us, it will hit us, and we will lose a life. Let’s set our RayCast node’s target_posiition to face our current direction in our process() function.

    ### Player

    #older code

    func _process(delta):
        if velocity.x > 0: # Moving right
            current_direction = 1
        elif velocity.x < 0: # Moving left
            current_direction = -1

        # If the direction has changed, play the appropriate animation
        if current_direction != last_direction:
            if current_direction == 1:
                #limits

                # Play the right animation
                $AnimationPlayer.play("move_right")

                # Set raycast direction to right
                $AttackRayCast.target_position.x = 50

            elif current_direction == -1:                
                #limits

                # Play the left animation
                $AnimationPlayer.play("move_left")

                # Set raycast direction to left
                $AttackRayCast.target_position.x = -50
Enter fullscreen mode Exit fullscreen mode

Godot 2D Platformer

Godot 2D Platformer

We have an issue where now we can successfully get rid of our box and our bomb, but our bomb still does damage to us even after we’ve hit it. To fix this issue, we can create a new variable in our Global script that will set if we can get damaged or not.

    ### Global.gd
    extends Node

    #older variables

    #can the player be damaged?
    var can_hurt = true
Enter fullscreen mode Exit fullscreen mode

In our Bomb and Box scripts, we need to only allow our body to take_damage() if our variable is set to true.

    ### Bomb.gd

    func _on_body_entered(body):
        #if the bomb collides with the player, play the explosion animation and start the timer
        if body.name == "Player":
            $AnimatedSprite2D.play("explode")
            $Timer.start()
            Global.is_bomb_moving = false
            #deal damage
            if Global.can_hurt == true:
                body.take_damage()
Enter fullscreen mode Exit fullscreen mode
    ### Box.gd

    func _on_body_entered(body):
        # If the bomb collides with the player, play the explosion animation and disable spawning
        if body.name == "Player":
            $AnimatedSprite2D.play("explode")
            # Disable spawning in BoxSpawner
            Global.disable_spawning()
            #deal damage
            #deal damage
            if Global.can_hurt == true:
                body.take_damage()
Enter fullscreen mode Exit fullscreen mode

If we are taking damage, let’s ensure that our player can only get hurt if the variable is set to true in our Player scripts take_damage() function.

    ### Player.gd

    #older code

    # takes damage
    func take_damage():
        #deduct and update lives    
        if lives > 0 and Global.can_hurt == true:
            lives = lives - 1
            update_lives.emit(lives, max_lives)
            #play damage animation
            $AnimatedSprite2D.play("damage")
            #allows animation to play
            set_physics_process(false)
Enter fullscreen mode Exit fullscreen mode

When our timer runs out after our attack boost is finished, we need to reset our can_hurt variable back to false, since we can get hurt if we aren’t attacking anymore.

    ### Player.gd
    #older code

    #attack boost timer timeout (when it reaches 0)
    func _on_attack_boost_timer_timeout():
        #sets attack back to false if the time on boost runs out
        if attack_time_left <= 0:
            Global.is_attacking = false
            Global.can_hurt = true
Enter fullscreen mode Exit fullscreen mode

Now in our physics_process() function, let’s update our code to disable our damage if we are pressing our input when focused on a Box or a Bomb, and if we aren’t pressing our input, we’ll reset our variable to damage us again. This will ensure that we can only not be damaged if our player is pressing the ui_attack input when focused on a target. If they aren’t pressing anything and they are focused on a target they can still be damaged.

    ### Player.gd

    #older code

    #movement and physics
    func _physics_process(delta):
        #older code

        #countdown for attack boost
        if Global.is_attacking == true:
            attack_time_left = max(0, attack_time_left - 1)
            update_attack_boost.emit(attack_time_left)

            if Input.is_action_pressed("ui_attack"):
                #gets the colliders of our raycast
                var target = $AttackRayCast.get_collider()
                #is target valid
                if target != null:
                    #remove box
                    if target.name == "Box":
                        Global.disable_spawning()
                        target.queue_free()
                    #remove bomb
                    if target.name == "Bomb":               
                        Global.is_bomb_moving = false   
                Global.can_hurt = false
            else:
                Global.can_hurt = true
Enter fullscreen mode Exit fullscreen mode

Now if you run your scene and you run over your attack boost pickup, you should be able to hit the boxes and bombs without being damaged, but if you’re just standing there whilst the attack boost is active, you should still be damaged.

Godot 2D Platformer

STEP 3: ATTACK BOOST UI

Now let’s display our timer value on a UI element. Copy your Health node under your UI tree and rename it to “Attack”.

Godot 2D Platformer

Change the Sprite2D texture to “res://Assets/lightning bolt/lightning bolt/sprite_4.png”. Change its position property to be (x: 25, y: 19).

Godot 2D Platformer

Godot 2D Platformer

Then, change the Attack ColorRect’s Transform values to position (x: 140, y: 20).

Godot 2D Platformer

Change the Label node’s size to be (x: 50, y: 23) and re-anchor it to the “center-right” position.

Godot 2D Platformer

It should look like this:

Godot 2D Platformer

Attach a new script to your Attack node and save it under your GUI folder.

Godot 2D Platformer

Let’s do the same for this script as we did for our Health.gd script. Create a new function that will update the attack_time_left value.

    ### Attack.gd

    extends ColorRect

    #ref to our label node
    @onready var label = $Label

    # updates label text when signal is emitted
    func update_attack_boost(attack_time_left):
        label.text = str(attack_time_left)
Enter fullscreen mode Exit fullscreen mode

In our ready() function, let’s connect our function to our signal.

    ### Player.gd

    #older code

    func _ready():
        current_direction = -1
        #set our attack timer to be the value of our wait_time
        attack_time_left =  $AttackBoostTimer.wait_time

        #updates our UI labels when signals are emitted
        update_lives.connect($UI/Health.update_lives)
        update_attack_boost.connect($UI/Attack.update_attack_boost)

        #show our correct lives value on load
        $UI/Health/Label.text = str(lives)
Enter fullscreen mode Exit fullscreen mode

Your code should look like this.

Now if you run your scene and you run through your boost, your timer should countdown and it should be reflected in your UI element.

Godot 2D Platformer

Congratulations on setting up the base for your Attack Boost System. In the next part, we’ll be adding the final piece to our Pickup system — which is our score boost pickup. We’ll first have to set up our player’s score, and thereafter we’ll set up the ability for our player to increase their score throughout the game loop.

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!

Godot 2D Platformer


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!

Godot 2D Platformer

This book will be updated continuously to fix newly discovered bugs, or to fix compatibility issues with newer versions of Godot 4.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .