In the previous tutorial, we added enemies to the game, but for now they just move around the game map following the player. In this one, we will add the possibility to attack them, both using the sword and casting fireballs. In turn, the enemies will attack the player back with their weapon.
Adding cooldown time to attack
In the 2D Sprite Animation tutorial we have already written the code that handles the attack input. At the moment, every time the player presses the spacebar or the attack button on the joypad, the sword attack animation is played. As a result, it’s theoretically possible to attack an indefinite number of times per second, depending on how quickly the player presses the button (but the animation can’t keep up with keystrokes, so it may not seem so).
So, the first thing we need to do is introduce the so-called Attack Cooldown, that is the amount of delay between two attacks.
Add these variables to the Player.gd script:
# Attack variables var attack_cooldown_time = 1000 var next_attack_time = 0 var attack_damage = 30
- attack_cooldown_time: this variable represents the minimum time (in milliseconds) that must pass between one attack and another.
- next_attack_time: Godot keeps track of the amount of time passed since the engine started; this time can be used as a reference for planning future actions. This variable will stores the time in which it will be possible to attack again.
- attack_damage: the amount of points that will be subtracted from enemy’s health when hit.
To add attack cooldown, edit the code that handles the attack in the _input() function in this way:
if event.is_action_pressed("attack"): # Check if player can attack var now = OS.get_ticks_msec() if now >= next_attack_time: # Play attack animation attack_playing = true var animation = get_animation_direction(last_direction) + "_attack" $Sprite.play(animation) # Add cooldown time to current time next_attack_time = now + attack_cooldown_time
The first change is the call to the get_ticks_msec() method of the OS object. OS wraps the most common functionality to communicate with the host Operating System, such as: clipboard, video mode, date and time, timers, environment variables, execution of binaries, command line, etc. The get_ticks_msec() function returns the current time as the amount of time passed in milliseconds since the engine started. If the current time is equal or greater then next_attack_time, the function play the attack animation.
At the end of the function, there’s a new line that update the time when the player will be able to make the next attack, calculated as the sum of the current time and the cooldown time.
If you test the game now, you’ll see that you can only make one attack per second.
Checking if enemy is being hit
When the player makes an attack, we must check if there is a monster in front of him and within the sword’s range.
Godot has a node that was designed to do things like this: RayCast2D. A RayCast2D node represents a line from its origin to its destination position, that can be used to query the 2D space in order to find the closest object along the path of the ray.
So, add a RayCast2D node to Player:
In the Inspector, turn on the Enabled property and set Cast To to (0,8) to set the ray to its initial direction and length (down, 8 pixels).
When the player move, we need to rotate the ray to point it in the direction the player is facing. To do so, add this code to the end of the _physics_process() function:
# Turn RayCast2D toward movement direction if direction != Vector2.ZERO: $RayCast2D.cast_to = direction.normalized() * 8
If you try the game now, enabling the Debug → Visible Collision Shapes menu, you will see in front of the player an arrow (representing the RayCast2D node) that rotates in the direction of the player movement.
At this point, we only have to check if the ray hits a skeleton when the player attacks. Edit the code that handles the attack in the _input() function in this way:
if event.is_action_pressed("attack"): # Check if player can attack var now = OS.get_ticks_msec() if now >= next_attack_time: # What's the target? var target = $RayCast2D.get_collider() if target != null: if target.name.find("Skeleton") >= 0: # Skeleton hit! target.hit(attack_damage) # Play attack animation attack_playing = true var animation = get_animation_direction(last_direction) + "_attack" $Sprite.play(animation) # Add cooldown time to current time next_attack_time = now + attack_cooldown_time
To get the first object that collide with the ray, we use the get_collider() function of the RayCast2D node.
If the returned object is not null, we check that its name contains the “Skeleton” string using String‘s find() function, which returns either the index of the first occurrence of the string, or -1 if it’s not found.
If the object is a Skeleton, we call its hit() method, to which we pass attack_damage as an argument. This method, which we will write shortly, will apply the damage to the skeleton and handles all the consequences (such as the skeleton death).
The skeleton, like the player, needs some variables to store its characteristics and its current state. So, add these variables to Skeleton.gd:
# Skeleton stats var health = 100 var health_max = 100 var health_regeneration = 1
Then, add the _process() function to handle health regeneration:
func _process(delta): # Regenerates health health = min(health + health_regeneration * delta, health_max)
Inflicting damage to enemies
When the skeleton is hit during an attack, we called the hit() function. Add this function to the Skeleton.gd script:
func hit(damage): health -= damage if health > 0: pass #Replace with damage code else: pass #Replace with death code
The hit() function simply subtracts the damage passed to it as an argument from the current health value. At this point, two things can happen:
- health remains above zero
- health drops to zero or less and the skeleton dies.
Let’s start by analyzing the case when the skeleton is hurted but does not die.
In this case, we want skelton’s sprite to turn red for a moment, to give the player a visual feedback that the skeleton was hit.
To give the sprite a red tint, we’ll use its modulate property, inherited from CanvasItem. This property is a color, which is used as a multiplier when drawing on the screen. By default this property’s value is (1, 1, 1, 1). The red, green, blue and alpha components are all 1, so the colors of the sprite remain unchanged (because all the components are multiplied by 1).
If we set modulate to (1, 0, 0, 1), the green and blue components of the sprite would be canceled (as they are multiplied by zero) and therefore only the red component would remain visible, obtaining the result we want.
To animate this property of a node, we must use the AnimationPlayer node. Open the Skelton.tscn scene and add an AnimationPlayer node to it:
Once the AnimationPlayer node is added, the Animation panel will open automatically in the bottom of the editor.
To create a new animation, click on Animation and choose New.
Name the animation Hit and press Ok.
An animation is made of a series of tracks, each of which animates a property of a node. To animate the modulate property of Skeleton, click on Add Track and choose Property Track.
Now you have to choose the node to animate. Select AnimatedSprite and press Ok.
Finally, select the modulate property and click Open. If you want, you can use the Search field to quickly find the property you want to animate.
We want to create a very simple animation: the skeleton must turn red for 2 tenths of a second, and then return to normal. So first, let’s set the animation length to 0.2 seconds.
Next, we need to create the animation keyframes. Right-click on the modulate track at the 0 second position, and choose Insert Key.
A new keyframe will be added. Repeat the operation at the 0.2 second position to get this result:
You can use the zoom slider in the bottom-right of the panel to change the time scale and make it easier for you to work. If you place a keyframe in the wrong time position, you can move it by dragging it along the track.
Now, click on the first keyframe you created. In the Inspector, you can set the Value of the modulate property at that point in the animation.
Click on the white rectangle next to Value and set the color to (255, 0, 0, 255). In the Animation panel, you will see that the line joining the two keyframes will gradually change from red to white, indicating that there is a transition in that property.
Please note: in the editor, the color components have values ranging from 0 to 255 (8-bit components), because they represent 32-bit RGBA colors. Instead, Color objects (like the modulate property) store components as floats, so they have values ranging from 0.0 to 1.0. If you want to use float values in the editor, you can enable the Raw Mode option in the color selection panel.
The last thing to do to complete the Hit animation is to change the transition type. If you 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 second keyframe is reached. To do this, we change the transition type to Discrete.
To play this animation when the skeleton is hit, open the Skeleton.gd script and edit the hit() function like this:
func hit(damage): health -= damage if health > 0: $AnimationPlayer.play("Hit") else: pass #Replace with death code
When the skeleton’s health drops to zero or less, the skeleton “dies”. In this case, we must stop all its activities (artificial intelligence and movement), play the death animation (which we created in the previous tutorial), and emit a signal that the monster spawner will use to update the skeletons count.
Let’s start by declaring this new signal:
Then add the code to handle death in the hit() function:
func hit(damage): health -= damage if health > 0: $AnimationPlayer.play("Hit") else: $Timer.stop() direction = Vector2.ZERO set_process(false) other_animation_playing = true $AnimatedSprite.play("death") emit_signal("death")
When the skeleton dies, the first thing we do is to stop the timer that handles artificial intelligence. Then, we set direction to Vector2.ZERO to stop movement.
The set_process() function is used to enable/disable the _process() function call at each frame. We disable it, to prevent the regeneration of skeleton’s health.
Finally, we play the death animation and emit the signal for the spawner.
Once the death animation has been completed, we can remove the skeleton from the scene tree. To do this, edit _on_AnimatedSprite_animation_finished() to handle the end of the death animation:
func _on_AnimatedSprite_animation_finished(): if $AnimatedSprite.animation == "birth": $AnimatedSprite.animation = "down_idle" $Timer.start() elif $AnimatedSprite.animation == "death": get_tree().queue_delete(self) other_animation_playing = false
SceneTree‘s queue_delete() function queues the given object for deletion, delaying the call to Object.free() to after the current frame.
Now, we need to connect the death() signal to the spawner. If you use the usual method for connecting signals, you’ll see that you can’t select nodes outside the Skeleton scene. We have to connect the signal from the spawner script.
Open the SkeletonSpawner.gd script and add this code to the instance_skeleton() function, after add_child() call:
# Connect Skeleton's death signal to the spawner skeleton.connect("death", self, "_on_Skeleton_death")
The connect() method, inherited from Object, is used to connect a signal (whose name is passed as the first argument) to a method of an object (passed as the third and second parameters respectively). We use this function to connect the death() signal to the _on_Skeleton_death() function of this object (self).
The function that handles the signal is very simple, it simply decrease the quantity of skeletons by one:
func _on_Skeleton_death(): skeleton_count = skeleton_count - 1
Run the game and try to kill some skeletons.
The skeleton attack works in a very similar way to that of the player. The only big difference is that it does not depend on player’s input, but must be automatic.
To start, let’s add to Skeleton.gd the same attack variables we added to Player (only the values change):
# Attack variables var attack_damage = 10 var attack_cooldown_time = 1500 var next_attack_time = 0
Go to the Skeleton.tscn scene and add a RayCast2D node to Skeleton. In the Inspector, turn on the Enabled property and set Cast To to (0,16).
Then, at the bottom of the _physics_process() function of the Skeleton.gd script, add the code to rotate the RayCast2D to the movement direction:
# Turn RayCast2D toward movement direction if direction != Vector2.ZERO: $RayCast2D.cast_to = direction.normalized() * 16
As we said earlier, the attack is not activated by pressing a button like for the player, but it must be automatic. So, in the _process() function, we need to check if RayCast2D hits the player. If so, we attack it.
Since the player may have moved in the meantime, we don’t immediately damage him, but we wait for frame 1 of the attack animation, when the skeleton’s scythe is fully extended, to check if Player is still in front of the skeleton and within range.
Add this code to _process():
# Check if Skeleton can attack var now = OS.get_ticks_msec() if now >= next_attack_time: # What's the target? var target = $RayCast2D.get_collider() if target != null and target.name == "Player" and player.health > 0: # Play attack animation other_animation_playing = true var animation = get_animation_direction(last_direction) + "_attack" $AnimatedSprite.play(animation) # Add cooldown time to current time next_attack_time = now + attack_cooldown_time
The code is almost identical to Player‘s attack, apart from the fact that the player is damaged later.
Since we need to check if the player is still in range at frame 1 of the attack animation, we will connect the frame_changed() signal of AnimatedSprite to Skeleton. Connect the signal and edit the code of the function that handles it:
func _on_AnimatedSprite_frame_changed(): if $AnimatedSprite.animation.ends_with("_attack") and $AnimatedSprite.frame == 1: var target = $RayCast2D.get_collider() if target != null and target.name == "Player" and player.health > 0: player.hit(attack_damage)
At the beginning, the function checks if the current animation is an attack animation and if the current frame is 1. If these conditions are true, it checks if the player is still in front of the skeleton and within range and, in that case, it calls Player‘s hit() function.
When Player is hit but does not die, we want its Sprite node to pulse in red color, like we did for the skeletons. So, go back to the main scene add an AnimationPlayer node to Player and create the Hit animation, following exactly the same procedure we used previously for the Skeleton node.
Once done, add the hit() function to the Player script:
func hit(damage): health -= damage emit_signal("player_stats_changed", self) if health <= 0: pass else: $AnimationPlayer.play("Hit")
The only difference with Skeleton‘s hit() function is that, after the health is decreased, this function emits the signal that updates the GUI.
When the player dies, we’ll show a very simple Game Over screen.
In the main scene, add a ColorRect node as a child of CanvasLayer, and name it GameOver. With GameOver selected, in the Inspector set the Rect → Size property to (320, 180) and set its Color to a dark red, for example (140, 0, 0, 255). Then set the Mouse → Filter property to Ignore to prevent the ColorRect to intercept mouse events (this is required if you use the code to drag the player with the mouse).
Add a Label node to GameOver and go to the Inspector. Set the Text property to GAME OVER and Align to Center. Then, in the Custom Fonts section, set Font to the Font.tres resource we created in the GUI tutorial. Finally, set Rect → Position to (125, 85) and Rect → Size to (70, 10).
We want the game over screen to fade in when the player die, so we must animate its modulate property from (255, 255, 255, 0) to (255, 255, 255, 255). To animate it, we can use the same AnimationPlayer node that we created before, even if it’s a child of Player, because it can access all the nodes of the scene regardless of its position in the scene tree.
So, select AnimationPlayer and create a new animation. called Game Over, to animate the modulate property of the GameOver node. Follow the procedure seen before, but this time, the animation will have to be 1 second long, so place the second keyframe at that time. For the first keyframe, set the Value of modulate to (255, 255, 255, 0). Also, don’t change the transition type to Discrete: this time we want it to be Continuos. You’ll get something like this:
As a last step, in the Inspector set the modulate property of GameOver to (255, 255, 255, 0) to hide it (you’ll find it in the CanvasItem → Visibility section).
To play the animation when the player die, edit Player‘s hit() function:
func hit(damage): health -= damage emit_signal("player_stats_changed", self) if health <= 0: set_process(false) $AnimationPlayer.play("Game Over") else: $AnimationPlayer.play("Hit")
Before playing the animation, the function call set_process() to disable the _process() function, to prevent player’s health from regenerating.
Run the game and let the skeletons kill you to test the game over screen.
The last thing left to do to complete the game’s combat system, is fireballs casting.
First, let’s create a new folder inside Entities called Fireball. Then, download the fireball images by clicking the button below. Once downloaded, import them into the Fireball folder (remember to re-import the images disabling Filter).
Create a new scene for the fireball, and use an Area2D node as the root node by clicking on the Custom Node button. We will use Area2D to detect when the fireball collides with a skeleton or other obstacles in the scene.
Someone might ask: why not use a KinematicBody2D for the fireball? It would be the best solution but, unfortunately, in Godot tilemaps do not support layers and collision masks at single cell level, so the fireball would also be stopped by water tiles. We have to use a custom system to allow the fireballs to cross water but be stopped by other obstacles.
Rename Area2D to Fireball, and add an AnimatedSprite to it. In the Inspector, create a new SpriteFrames resource and create the following animations (if you don’t remember how to do it, watch the 2D Sprite Animation tutorial):
Set AnimatedSprite‘s Animation property to fly and enable Playing.
Add a CollisionShape2D node to Fireball and set its Shape property to a new new CircleShape2D. Click on CircleShape2D and set its Radius property to 3.
Now, add a Timer node to Fireball, set its Wait Time to 2 seconds and enable Autostart. We will use this timer to self-destruct the fireball if it does not encounter an obstacle within 2 seconds.
Attach a new script to Fireball, call it Fireball.gd and save it in Entities/Fireball.
Start adding this variables to the script:
var tilemap var speed = 80 var direction : Vector2 var attack_damage
The tilemap variable will contain a reference TileMap, which will let us know if the fireball is above water. We will use speed and direction variables for fireball movement, while attack_damage will store the damage points to be subtracted from a skeleton when hit.
When instantiating the fireball, the first thing to do is to get the reference to Tilemap in the _ready() function:
func _ready(): tilemap = get_tree().root.get_node("Root/TileMap")
The position of the fireball must be recalculated at each frame. We will do it inside the _process() function:
func _process(delta): position = position + speed * delta * direction
The position is updated by adding to the current position the movement in this frame, calculated as speed * delta * direction.
Let’s move on to collision detection. Area2D nodes emit the _body_entered() signal whenever a body enters its collision shape. Connect Fireball‘s _body_entered() signal to the node itself . The _on_Fireball_body_entered() function will be created automatically.
In this function we will check with what kind of object the fireball collided. If it has collided with a water tile, the collision will be ignored. Otherwise, the fireball will explode and, if the object is a skeleton, it will inflict damage to it by calling the hit() function.
Enter this code for the _on_Fireball_body_entered() function:
func _on_Fireball_body_entered(body): # Ignore collision with Player and Water if body.name == "Player": return if body.name == "TileMap": var cell_coord = tilemap.world_to_map(position) var cell_type_id = tilemap.get_cellv(cell_coord) if cell_type_id == tilemap.tile_set.find_tile_by_name("Water"): return # If the fireball hit a Skeleton, call the hit() function if body.name.find("Skeleton") >= 0: body.hit(attack_damage) # Stop the movement and explode direction = Vector2.ZERO $AnimatedSprite.play("explode")
The function argument body is the object that has entered the collision shape of the fireball. The first thing that the function does is to check if body is the player, and if so, the function exits immediately. When the fireball is launched, Player is inside Area2D‘s shape, and obviously we want to prevent the fireball from exploding immediately.
When Fireball collides with a TileMap‘s tile, the object passed to the _on_Fireball_body_entered() function is the entire TileMap. We must therefore understand what kind of tile we have collided with. Using the same TileMap and TileSet methods we used in the monster tutorial, the function checks if the fireball is over a water tile. If so, it immediately exits.
The next section of code checks if the object is a skeleton, and call the hit() function to damage it.
The end of the function is reached only if the fireball hit something. The fireball stops moving (setting direction to Vector2.ZERO) and the explosion animation is played.
When the explosion animation ends, we can remove the fireball from the scene tree. Connect the animation_finished() signal of AnimatedSprite to Fireball, and in the script edit the function that was automatically created:
func _on_AnimatedSprite_animation_finished(): if $AnimatedSprite.animation == "explode": get_tree().queue_delete(self)
If the fireball does not hit anything, it will self-destruct in 2 seconds. To do this, link Timer‘s timeout() signal to Fireball to play the explosion animation:
func _on_Timer_timeout(): $AnimatedSprite.play("explode")
Finally, save the scene as Fireball.tscn inside the Entities/Fireball folder.
Instantiating a fireball
Like for normal attacks, even for fireballs we’ll need some variables for cooldown and to store the damage points. In Player.gd, add this code:
# Fireball variables var fireball_damage = 50 var fireball_cooldown_time = 1000 var next_fireball_time = 0
Since we will have to instantiate the fireball scene every time we cast one, we need a reference to it:
var fireball_scene = preload("res://Entities/Fireball/Fireball.tscn")
Then, in the _input() function, edit the code that handles the fireball input to include the cooldown (it is similar to what we did for the sword attack):
elif event.is_action_pressed("fireball"): var now = OS.get_ticks_msec() if mana >= 25 and now >= next_fireball_time: # Update mana mana = mana - 25 emit_signal("player_stats_changed", self) # Play fireball animation attack_playing = true var animation = get_animation_direction(last_direction) + "_fireball" $Sprite.play(animation) # Add cooldown time to current time next_fireball_time = now + fireball_cooldown_time
When the fireball animation ends, the function instantiates a new fireball scene, placing it 4 pixels away in front of the player. Add this code to the bottom of the _on_Sprite_animation_finished() function:
if $Sprite.animation.ends_with("_fireball"): # Instantiate Fireball var fireball = fireball_scene.instance() fireball.attack_damage = fireball_damage fireball.direction = last_direction.normalized() fireball.position = position + last_direction.normalized() * 4 get_tree().root.get_node("Root").add_child(fireball)
Now run the game and try to cast fireballs to the skeletons.
In this tutorial we learned:
- how to implement attack cooldown
- how to use the RayCast2D node to detect other objects
- how to use AnimationPlayer to animate node’s properties
- how to connect signals and nodes that are in different scenes
- how to overcome some limitations in TileMap collisions
Some things we’ve done in this tutorial are simplified compared to what you normally would do. For example, the Game Over screen is usually created as a separate scene, and allows the player to start a new game or exit the game. I believe that if you followed all the tutorials in the series, you should be able to create a more complete Game Over screen on your own. So, I’ll leave it as an exercise.
If you want to try the game, click on the button below:
In the next tutorial, we will learn how to pick up and use items.