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

Skeleton’s stats

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

Skeleton’s death

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:

signal death

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.

Skeleton’s attack

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.

Taking damage

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.

Player’s death

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.

Casting fireballs

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

Download “SimpleRPG Fireball Frames” fireball_frames.zip – 2 KB

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

NameFramesSpeed (FPS)Loop
flyfireball_fly_1.png
fireball_fly_2.png
fireball_fly_3.png
fireball_fly_4.png
5Yes
explodefireball_explode_1.png
fireball_explode_2.png
fireball_explode_3.png
fireball_explode_4.png
5No

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.

Conclusions

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.




3 Comments

Ben · December 23, 2019 at 3:21 am

Great work, as always. One question and one suggestion:

Question: Why did we not create Player as a separate scene like we did with Skeleton and fireball? The little bit that I know about game structure in Godot suggests to me that having Player be its own scene would be the most sensible way to do it for organizational and collaborative reasons. Is there a reason you chose to not do so?

Suggestion: Maybe this will be added in a later tutorial (I’m a little behind and I’m just now catching up), but it might be a good idea to set the fireball’s direction to Vector2.ZERO in _on_Timer_timeout(), like we did for when it collides with something. As it stands, the fireball times out correctly but continues traveling forward while it explodes, which just looks kind of odd.

    Davide Pesce · December 23, 2019 at 11:29 am

    Hi Ben!

    As you correctly said, the best solution is to make Player a separate scene. Even the map should be put in a separate scene, to load it only when really needed (for example if you divide the game world into several areas or when you go from the start screen to the actual game).

    The choice to put everything in one scene is just to keep it simple. These tutorials are aimed at those who have never seen Godot or any other game engine, so sometimes I made choices that for an expert could be awful, but which I think make learning smoother. I don’t know if I did it, so any feedback is welcome.

    Regarding the fireball, from a physics point of view it’s correct that the explosion continues to move by inertia. I didn’t worry too much if it also works from a gameplay point of view, because most of the times it occurs off-screen. If you prefer the explosion to stop, your suggestion to set fireball’s direction to Vector2.ZERO totally works!

      Ben · December 23, 2019 at 3:45 pm

      Got it, thanks! Regarding the game structure, that’s pretty much what I figured. Mainly, I just wanted to make sure my intuition was correct! The way you’re doing it definitely makes sense, though I don’t know that having everything be separate scenes would necessarily make it more complicated or difficult to understand.

      As for the fireball, I guess all the games I’ve played stop projectiles when they explode so having it behave this way looks odd to me. Now that I think the physics through, I agree that it would work similarly to what we’ve got here.

      Thanks again!

Leave a Reply

Your email address will not be published. Required fields are marked *

By continuing to browse this Website, you consent to the use of cookies. More information

This Website uses:

By continuing to browse this Website without changing the cookies settings of your browser or by clicking "Accept" below, you consent to the use of cookies.

Close