No game can be complete without some sort of threat or enemy to defeat. In our game, we will have a cactus enemy that spawns on the map at a constant value, meaning that there will never be more or less than x amount of enemies on our map during the game loop. This enemy will damage our player if our player touches it, and it will also shoot at our player. Let’s get started with our enemy AI.
WHAT YOU WILL LEARN IN THIS PART:
· How to add movement to non-controllable nodes.
· How to work with the Timer node.
· How to work with the RandomNumberGenerator class.
· How to make nodes move around randomly, as well as move towards other nodes.
Figure 13: Enemy Overview
ENEMY SCENE SETUP
Our Enemy scene will have the same structure as our Player scene, with a CharacterBody2D as its root node followed by an AnimatedSprite2D node and a CollisionShape2D node. Therefore, we can just go ahead and duplicate our Player scene and rename it to “Enemy” or “Cactus”. I’m only going to have a Cactus as my enemy, so I’ll call mine the general term “Enemy”, but if you’re going to have multiple different enemies, you can create a scene for “Cactus”, “Bandit”, “Tumbleweed”, etc.
Rename your scene Root to whatever you called it (such as Enemy) and detach the Player script from your scene.
You should also delete the Camera2D node since we won’t follow this node around and also disconnect your on_animation_finished() signal from your AnimatedSprite2D node. Your final scene should look like the image below.
This Enemy scene will constantly have to be monitored to update its behavior. For example, let’s say we want it to roam around for 1 minute, and after that 1 minute, we want it to stop for 30 seconds before redirecting and roaming again. We can use a Timer node that will emit its built-in timeout() signal after it counts down a specified interval until it reaches 0. So each time the timer times out, the enemy should redirect and roam again.
Your timer has two options: *one-shot *(if true, the timer will stop when reaching 0. If false, it will restart) and *autostart *(if true, the timer will automatically start when entering the scene tree). We want this timer to start as soon as our game starts because we want to use it to update our enemy’s movements after a certain amount of time. Therefore, you need to enable the Autostart property in the Timer node’s Inspector panel.
We will come back to this timer node later on when we add the functionality for our enemy’s roaming. For now, let’s set up the enemy’s animations just like we did for our Player. We already have all of our animation names set up for our Enemy since they will be able to do exactly what our Player does, we just need to switch out the animation frames.
To do that you need to delete the existing sprite frames in your animations. Do this for all the animations, but don’t delete any animations.
Let’s start with our attack_down animation. Select the option to “add new sprite frames from sprite sheet” option, and in your Assets > Mobs > Cactus directory, you will find all of your sprite sheets for your enemy. For our attack_down animation, we will use the “Cactus Front Sheet.png” sheet to create our animation.
We count 11 frames horizontally and 4 frames vertically, so change your numbers accordingly to crop out your frames correctly. For attack down, we will use the frames in the third row.
I challenge you to now add the rest of the animations on your own using the table below as a guide.
MOVING THE ENEMY
Now we want to be able to move the enemy around autonomously. For that, we need to create a few variables that will store our enemy’s direction, and new direction after a random amount of time has passed. Instead of making it a set amount that they will wait 1 minute before redirecting, we will randomize this value. We also want our enemy to redirect after colliding with objects.
Let’s attach a new script to the Enemy scene and save it under your Scripts folder.
Our enemy’s movement will work similarly to our player’s movement, so let’s add some familiar variables from our Player.gd script to capture its movement speed and new direction. We also need to create a variable that will store its current direction.
### Enemy.gd
extends CharacterBody2D
# Enemy movement speed
@export var speed = 50
#it’s the current movement direction of the cactus enemy.
var direction : Vector2
#direction and animation to be updated throughout game state
var new_direction = Vector2(0,1) #only move one spaces
Our direction will change when our timer runs out after a randomized countdown. We will generate this random countdown value using the RandomNumberGenerator class. As the name says, it’s a class for generating pseudo-random numbers. The new() method is used to create an object from a class.
### Enemy.gd
# older code
# RandomNumberGenerator to generate timer countdown value
var rng = RandomNumberGenerator.new()
#timer reference to redirect the enemy if collision events occur & timer countdown reaches 0
var timer = 0
We’ll also need to move the enemy towards the player if they spot our player in a certain radius, so let’s add a reference to our player scene.
### Enemy.gd
extends CharacterBody2D
# older code
#player scene ref
var player
Now that we have defined our variables, we can go ahead and initialize our random number and player reference in our built-in ready() function, because we want these objects to be initialized as soon as our Enemy scene enters our Main scene.
We will connect our player reference to the Player node in our Main scene. Since the Enemy scene will also be instanced in the Main scene — hence sharing a scene tree with the Player scene, we can get our player by the get_tree().root.get_node method. Main is our Main scene, and /Player is the Player instance in our Main scene.
### Enemy.gd
extends CharacterBody2D
# Node refs
@onready var player = get_tree().root.get_node("Main/Player")
# Enemy stats
@export var speed = 50
var direction : Vector2 # current direction
var new_direction = Vector2(0,1) # next direction
# Direction timer
var rng = RandomNumberGenerator.new()
var timer = 0
func _ready():
rng.randomize()
Next, let’s add our enemy’s movement code. The coding process for our enemy’s movement will be similar to that of our player. First, we’ll add the code to move them. Then, we simply need to add our timer to redirect our enemy and move them toward the player if they “see” us. After we’ve done those things, we’ll build on that to change their animations according to their movement direction. In this part, we will add the redirection and movement, but not the animations yet.
Next, let’s add our enemy’s movement code. The coding process for our enemy’s movement will be similar to that of our player. First, we’ll add the code to move them. Then, we simply need to add our timer to redirect our enemy and move them toward the player if they “see” us. After we’ve done those things, we’ll build on that to change their animations according to their movement direction. In this part, we will add the redirection and movement, but not the animations yet.
We’ll also add our enemy’s movement code in our physics_process() function since we put all things related to our node’s movement and physics in this code. Let’s start by adding the functionality for them to move via our move_and_collide method, just like we did for our player.
### Enemy.gd
# older code
# Apply movement to the enemy
func _physics_process(delta):
var movement = speed * direction * delta
var collision = move_and_collide(movement)
Now, we need to connect our Timer node’s timeout() signal to our script. This signal will emit when our timer reaches 0. You’ll see that it creates a *func _on_timer_timeout(): *function at the end of our script.
In this timeout function, we need to do a few things. First, we need to calculate the player’s position relative to our enemy. We can find our player’s position returned as a Vector(0,0) value by accessing our node’s transform values (position, rotation, and scale). By knowing this, we can access our player’s position by simply saying: player.position — and if we wanted their rotation we could say player.rotation.x, and so forth. We can also access our current node’s position (which is our enemy node) by simply saying position or self.position.
After we get our player position, we need to minus it from our enemy’s position to get the player’s distance from our enemy. Let’s go ahead and get this value.
### Enemy.gd
# older code
# ------------------------- Movement & Direction ---------------------
# Apply movement to the enemy
func _physics_process(delta):
var movement = speed * direction * delta
var collision = move_and_collide(movement)
func _on_timer_timeout():
# Calculate the distance of the player relative position to the enemy's position
var player_distance = player.position - position
Now, if that distance is within 20 pixels of the enemy, it means that the enemy is close enough to the player that it doesn’t have to chase them, but it can go ahead and “attack” or “engage” with the player. You can make this sight value any number, but I’m going to go with 20 pixels.
### Enemy.gd
# older code
func _on_timer_timeout():
# Calculate the distance of the player relative position to the enemy's position
var player_distance = player.position - position
#turn towards player so that it can attack if within radius
if player_distance.length() <= 20:
new_direction = player_distance.normalized()
If they are within 100 pixels of the enemy and the timer has run out, it means that the enemy is not close enough to attack the player, so they’ll have to move towards and start chasing the player. You can make this chase value any number, but I’m going to go with 100 pixels.
### Enemy.gd
# older code
func _on_timer_timeout():
# Calculate the distance of the player relative position to the enemy's position
var player_distance = player.position - position
#turn towards player so that it can attack if within radius
if player_distance.length() <= 20:
new_direction = player_distance.normalized()
#chase/move towards player to attack them
elif player_distance.length() <= 100 and timer == 0:
direction = player_distance.normalized()
Otherwise, if the player is not close to the enemy, or not in our chase radius, then our enemy can go about its day and roam randomly. The enemy’s direction will be calculated randomly via the Vector.DOWN.rotate method, which will calculate a random angle between 0 to 360°. This direction will change each time the timer times out.
### Enemy.gd
# older code
func _on_timer_timeout():
# Calculate the distance of the player relative position to the enemy's position
var player_distance = player.position - position
#turn towards player so that it can attack if within radius
if player_distance.length() <= 20:
new_direction = player_distance.normalized()
#chase/move towards player to attack them
elif player_distance.length() <= 100 and timer == 0:
direction = player_distance.normalized()
#random roam
elif timer == 0:
#this will generate a random direction value
var random_direction = rng.randf()
#This direction is obtained by rotating Vector2.DOWN by a random angle.
if random_direction < 0.05:
#enemy stops
direction = Vector2.ZERO
elif random_direction < 0.1:
#enemy moves
direction = Vector2.DOWN.rotated(rng.randf() * 2 * PI)
Finally, we need to use our collider variable from our physics_process() function to see if our enemy is colliding with our Player, as well as add our timer range to randomize from. If they are colliding with our player, the timer needs to be set to 0 to trigger our timeout() function so that the enemy will chase us.
If they aren’t colliding with our player, we need to set our timer randomizer value as well as randomize their direction rotation value so that they can turn around if they collide with other objects. This rotation angle is obtained using the randf_range() function. This angle has a value between 45° to 90°. You can change these values to make it smoother or sharper if you’d like.
### Enemy.gd
# older code
# Apply movement to the enemy
func _physics_process(delta):
var movement = speed * direction * delta
var collision = move_and_collide(movement)
#if the enemy collides with other objects, turn them around and re-randomize the timer countdown
if collision != null and collision.get_collider().name != "Player":
#direction rotation
direction = direction.rotated(rng.randf_range(PI/4, PI/2))
#timer countdown random range
timer = rng.randf_range(2, 5)
#if they collide with the player
#trigger the timer's timeout() so that they can chase/move towards our player
else:
timer = 0
If you instance your Enemy scene in your Main scene, and you run it, then they should chase you or roam around.
If you instance your Enemy scene in your Main scene, and you run it, then they should chase you or roam around.
And so, we have added an enemy that isn’t any threat to us. Our enemy has no animations or any value to it yet, but that will come in the next few parts. In the next section, we will add the functionality to add animations to our enemy’s movement. Luckily, we’ve already added our animations, so it will be a quick setup to display these animations in our enemy’s movement! Remember to save your game project, and I’ll see you in the next part.
Your final code for this part should look like this.
FULL TUTORIAL
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.