Skip to main content

Command Palette

Search for a command to run...

Building a fantasy top-down game in Python (Part 3)

Enemy logic and projectile attacks

Published
12 min read
Building a fantasy top-down game in Python (Part 3)
F

An aspiring developer

Making the Enemy class

Okay, so now that we’ve made a good chunk of our game, we can finally move on to making our enemies. Its always a good idea to start by outlining what you want to accomplish before writing any code. For the enemies, I wanted there to be 3 different types of fairies, each of which would have different attacks, damage, cooldowns and speeds, just to make the game less repetitive.

Let’s start by outlining these different parameters in settings.py, a file which we can import anywhere in our project to gain access to these parameters without creating many copies of the same data structure.

enemy_data = {
    'Fairy 1' : {'health': 100, 'damage': 5, 'attack_r' : 100, 'notice_r' : 200, 'resistance' : 10, 'speed':5, 'cooldown' : 80},
    'Fairy 2' : {'health': 100, 'damage': 3, 'attack_r' : 80,'notice_r' : 300, 'resistance' : 25, 'speed': 8, 'cooldown' : 50},
    'Fairy 3' : {'health': 100, 'damage': 10, 'attack_r' : 50, 'notice_r' : 100, 'resistance' : 30,'speed': 6, 'cooldown' : 100}
}

Each fairy begins with 100% health, which will decrease in accordance to player attacks. ‘damage’ specifies how much the player’s health will decrease when the enemy attacks it. ‘attack_r’ and ‘notice_r’ are the attack and notice radius respectively, a.k.a, the distances from the player at which the fairies can attack, and ‘notice’ (chase) the player. ‘resistance’ is how much the fairies will sort of bounce back when attacked by the player. ‘speed’ is self-explanatory and cooldown is the amount of time after which the fairies can re-cast their attacks.

The base of the enemy class should look something like this, following the general ‘Tile’ template we used for everything else :

from pygame import *
from settings import *
from math import *

class Enemy(sprite.Sprite):
    def __init__(self, pos, groups, type, path, player, obstacles):
        super().__init__(groups) #adds the sprite to the given groups
        self.sprite_type = type #when creating the map, I gave each entity a certain class. enemy sprites under "enemy", gems under "collectible" etc
        self.current_sprite = 0.0 #to keep track of the animation
        self.enemy_type = path.split('\\')[3].split('.')[0]
        self.speed = enemy_data[self.enemy_type]['speed']
        self.health = enemy_data[self.enemy_type]['health']
        self.resistance = enemy_data[self.enemy_type]['resistance']
        self.attack_r = enemy_data[self.enemy_type]['attack_r']
        self.notice_r = enemy_data[self.enemy_type]['notice_r']
        self.damage = enemy_data[self.enemy_type]['damage']
        self.cooldown = enemy_data[self.enemy_type]['cooldown']
        self.status = 'idle'
        self.direction = math.Vector2()
        self.image = image.load(path)
        self.rect = self.image.get_rect(topleft = pos)
        self.mask = mask.from_surface(self.image)
        self.player = player #passing in an instance of the player to get its position and stuff
        self.attack_timer = 0 #this'll make sense later
        self.last_hit = 0 #this too

self.enemy_type is the type of fairy (1,2 or 3) extracted from the path to the image passed into the enemy object. We then use this variable to access the correct type of fairy’s information (speed, damage etc.) from enemy_data, the list we imported from settings.py. You’ll recognize that most of the class variables match our player class, and that’s because they’re essentially the same type of thing, but perform a different role.

My images for the enemies were in the form of spritesheets, so I used the same load_sprite_sheet function as the player class to load them in. The animation function is a bit different here though, since there isn’t a whole new spritesheet to render when the enemy is attacking (we’ll ignore attacks right now and work on them later in our projectile class) :

def animate(self, dir):
        sheet = f"levels//level_1//tilesets//{self.enemy_type}_spritesheet.png"
        sprites = self.load_sprite_sheet(sheet, 32, 64)
        self.current_sprite += 0.15
        if self.current_sprite >= 3:
            self.current_sprite = 0
        if dir == 'right':
            self.image = sprites[int(self.current_sprite)] # if the player is towards the right of the player, 
                                                           # the enemies will face right
        else:
            self.image = transform.flip(sprites[int(self.current_sprite)], True, False) #if not, we flip the enemies
                                                                                        #so that they face left
        self.mask = mask.from_surface(self.image)

The logic behind the animation is still the same as our Player and Hazard classes. Enemy movement is a bit different however, since they can go over obstacles that the player cannot.

def move(self, direction):
        #vector subtraction. V (direction) = VB (player) - VA (enemy)
        old_rect = self.rect.copy()
        if direction.length() != 0: #if the distance between them is not 0
            self.direction = direction.normalize()
        else:
            self.direction = math.Vector2(0, 0)

        self.trymove('h', self.direction.x*self.speed, old_rect)
        self.trymove('v', self.direction.y*self.speed, old_rect)

move() calls trymove() in both the horizontal and vertical directions, passing in a velocity vector and the old position of the enemy as arguments. I realized that simply moving the enemies in the direction of the player wasn’t the best idea, because the fairies ended up overlapping with each other and moving towards the player as a single entity. As a solution, I created a function trymove() which checks for enemy-enemy collisions and separates them.

def trymove(self,dir, val, old_rect):
        copy = sprite.Group() # i dont know why but copy = self.obstacles.copy() wasnt working so i had to add its sprites to copy using add
        copy.add(self.obstacles)
        copy.remove(self) # remove self from the copy and check if it collides with any other fairies

        if dir == "h":
            if sprite.spritecollideany(self, copy, sprite.collide_mask) == None:
                self.rect.x += val

            for spr in sprite.spritecollide(self, copy, False, sprite.collide_mask):

                if spr.sprite_type == 'enemy': #if two sprites overlap, move them a little so they dont overlap
                    offset = self.rect.centerx - spr.rect.centerx, self.rect.centery - spr.rect.centery
                    distance = hypot(*offset)
                    if distance < 32 and math.Vector2(offset).length != 0: #if distance between their centers is less than half a tile which means they're in the same tile
                        push_vector = math.Vector2(offset).normalize() * 5 
                        self.rect.x += push_vector.x 
                        self.rect.y += push_vector.y
                else: #we don't care about collision with obstacles like trees, rocks etc.
                    self.rect.x += val

        if dir == "v":
            if sprite.spritecollideany(self, copy, sprite.collide_mask) == None:
                self.rect.y += val
            for spr in sprite.spritecollide(self, copy, False, sprite.collide_mask): 
                if spr.sprite_type == 'enemy': #if two sprites overlap, move them a little so they dont overlap
                    offset = self.rect.centerx - spr.rect.centerx, self.rect.centery - spr.rect.centery
                    distance = hypot(*offset)
                    if distance < 32 and math.Vector2(offset).length != 0: #if distance between their centers is less than half a tile which means they're in the same tile
                        push_vector = math.Vector2(offset).normalize() * 5 
                        self.rect.x += push_vector.x 
                        self.rect.y += push_vector.y
                else: #we don't care about collision with obstacles like trees, rocks etc.
                    self.rect.y += val

Creating the ‘Projectile’ class to handle enemy attacks

Okay, so now that we have animated enemies that move towards the player without overlapping, we can move on to making enemy attacks! To keep the enemy class nice and short, I created a new class ‘Projectile’ to handle projectile attacks.

from pygame import *
from math import *
from settings import *
import glob #I'll explain this later 

class Projectile(sprite.Sprite):
    def __init__(self, pos, direction, speed, groups, enemy_type, player, enemy_that_shot_projectile):
        super().__init__(groups)
        self.sprite_type = "projectile" #remember we need this when detecting collisions
        self.enemy_type = enemy_type #the type of enemy that shot the projectile, Fairy 1, Fairy 2 etc.
        self.image = Surface((10, 10), SRCALPHA)
        self.rect = self.image.get_rect(center=pos)
        self.mask = mask.from_surface(self.image)
        self.cooldown = enemy_data[enemy_type]['cooldown']
        self.direction = direction.normalize()
        self.speed = enemy_data[self.enemy_type]['speed']
        self.lifetime = 30  # Frames until it disappears so like 1/2s frame at 60 FPS
        self.screen = display.get_surface()
        self.current_sprite = 0 
        self.player = player
        self.attackable = groups[1] #self.attackable (I'll discuss what this is
        self.enemy_that_shot_projectile = enemy_that_shot_projectile #a copy of the enemy object that shot the projectile

Again, the projectile class is just another spin on the ‘Tile’ class template. The class variables that are new to this class have comments outlining their purpose. For this to work, we need to create a new group called ‘attackable’, which will include the enemies and their projectiles; these are as the name suggests, things the player can attack. Let’s head back to our map loader class and replace the part where we create the ‘enemies’ layer with the following code :

if layer == "enemies":
    if col in self.tileset.keys():
         #y + 64-16 because the images are 16x16px not 64x64
       enemy = Enemy((x,y+52), [self.visible, self.obstacles, self.attackable], 'enemy',self.tileset[col], self.player, self.obstacles)
       enemy.projectile_group = [self.visible, self.attackable]

This initializes an object of the Enemy class and adds it to self.attackable as well as self.obstacles and self.visible. It also creates a class variable called ‘projectile_group’ that contains self.visible and self.attackable, which we’ll use when creating an object of the Projectile class.

Fixing our Player class

One problem that I ran into when making this game was that if there was more than one enemy nearby and the player was attacking, regardless of which enemy the attack was directed at, it killed all of them. To fix this, I created a function within the Player class called ‘attack’, which finds the ‘target’ enemy, a.k.a the enemy at which the attack was targeted.

def attack(self, enemies):
        direction = self.current_state
        attack_range = 100
        target_enemy = None

        for enemy in enemies:
            if enemy.sprite_type == "enemy":
                dx = enemy.rect.centerx - self.rect.centerx
                dy = enemy.rect.centery - self.rect.centery

                if direction == "r" and 0 <= dx <= attack_range and abs(dy) < 32: #chooses the enemy in the most appropriate direction, right, left, top or down
                    target_enemy = enemy

                elif direction == "l" and -attack_range <= dx <= 0 and abs(dy) < 32:
                    target_enemy = enemy

                elif direction == "u" and -attack_range <= dy <= 0 and abs(dx) < 32:
                    target_enemy = enemy

                elif direction == "d" and 0 <= dy <= attack_range and abs(dx) < 32:
                    target_enemy = enemy

                if target_enemy:
                    break  # only one enemy

        if target_enemy:
            self.target = target_enemy
            target_enemy.rect.x += target_enemy.resistance
            target_enemy.rect.y += target_enemy.resistance
            target_enemy.health -= attack_data[self.weapon]['damage']

The parameter ‘enemies’ is a bit of a misnomer here since what we pass in is actually the entire obstacle group (that’s why we check the sprite type within the loop). The way to find the target enemy is quite simple, you just check which direction the player is facing and check which of the enemies lie within the same tile (<32px) and the attack range (100px). Once the target enemy is found, you push it back by its resistance and decrease its health by the amount of damage of the weapon used. We can then call this function in the players update function by adding the lines :

    def update(self):
        global attacked #declare attacked as false at the beginning of the program
        self.input()
        self.move()
        if self.is_attacking:
            attacked = True
            self.attack(self.obstacles)
            if time.get_ticks() - self.attack_time > 360: #360s is the cooldown
                self.is_attacking = False #we can attack again after 360s

Class ‘Projectile’ continued

Okay, back to the Projectile class. The animation stays exactly the same, so you can use the animate() function from any of the other classes. In the update function however, we need to write some code :

def update(self): #THIS IS THE PROJECTILE CLASS' UPDATE FUNCTION NOT PLAYER
        self.animate()
        self.rect.x += self.direction.x * self.speed
        self.rect.y += (self.direction.y * self.speed)
        self.lifetime -= 1
        if self.lifetime <= 0:
            self.kill()
        if hasattr(self.player, 'shield') and self.player.shield != None: #can't attack when shield is active         
            if self.mask.overlap(self.player.shield.mask, (self.player.shield.rect.x - self.rect.x, self.player.shield.rect.y - self.rect.y)):
                self.kill()                         #offset is how far the masks are away from each other 
        if self.mask.overlap(self.player.mask, (self.player.rect.x - self.rect.x, self.player.rect.y - self.rect.y)):
            if self.player.target == self.enemy_that_shot_projectile and self.player.is_attacking:
                self.kill() #shot disappears if it hits the player
            else:
                self.player.health -= enemy_data[self.enemy_type]['damage'] #player only takes damage if he doesnt hit the projectile coming from the target enemy
                self.kill() #shot disappears if it hits the player

The direction the projectile moves in is the same as the enemy’s direction vector, so we just pass it in when creating the object. Each time the projectile updates, we decrease 1 from its lifetime, so after about 30 seconds, the projectile should disappear. Now, disappearing doesn’t mean that it doesn’t have any effect, it just means that like a real projectile, it stops once it covers a certain range. If the player gets hit, we decrease its health by the amount of damage caused by the type of enemy that shot the projectile, as defined in settings.py. But if the player attacks the projectile, we simply kill the projectile using self.kill().

Lets import this class into our Enemy file and create a function to call it :

def shoot_projectile(self):
        direction = math.Vector2(self.player.rect.x, self.player.rect.y) - math.Vector2(self.rect.x, self.rect.y) 
        if direction.length() != 0:
            direction = direction.normalize()
        else:
            direction = math.Vector2(0, 0)

        projectile = Projectile(self.rect.center, direction, 5, self.projectile_group, self.enemy_type, self.player, self)
        return projectile.cooldown

Again, this is pretty standard logic. We figure out the direction vector (the difference of the player and enemy vector) and pass it into the projectile object. We then return the cooldown which will be our enemy’s attack timer. Now we can call this function in the enemy’s update method to make it attack.

def update(self):
        if self.health <= 0:
            self.player.points += 2 #increase 2 points only when the enemy is actually dead
            self.kill()

        player_vector = math.Vector2(self.player.rect.center)
        enemy_vector = math.Vector2(self.rect.center)
        distance = player_vector.distance_to(enemy_vector)
        direction = (player_vector - enemy_vector)

        if self.player.rect.x > self.rect.x :
            dir = "right"
        else:
            dir = "left"

        if distance <= self.notice_r and distance >self.attack_r:
            self.move(direction) 
        if distance <= self.attack_r and self.attack_timer<=0:
            self.attack_timer = self.shoot_projectile()

        self.animate(dir)
        if self.attack_timer > 0:
            self.attack_timer -= 1

        if hasattr(self.player, 'shield') and self.player.shield:
            shield_center = self.player.shield.rect.center
            enemy_center = self.rect.center

            dx = enemy_center[0] - shield_center[0] # since its enemy - shield it'll create a vector pointing from the SHIELD to the ENEMY (opp dir)
                                                    # if enemy is to the left, dx = -ve, which'll make it move more to the left, if enemy is to the right, dx = +ve, which'll make it move more to the right
            dy = enemy_center[1] - shield_center[1]
            distance = hypot(dx, dy)

            if distance < 100:
                # moves the enemy away from the shield along the vector pointing from the shield to the enemy
                direction = math.Vector2(dx, dy)
                if direction.length() > 0:
                    direction = direction.normalize()
                    self.rect.x += direction.x * self.speed
                    self.rect.y += direction.y * self.speed
                return  # stops normal movement like the projectile

To sum up, we check the coordinates of the player and the enemy: if the player is to the right of the enemy, the enemy faces right and vice versa. Then, if the player is within the enemy’s notice radius, we call the move() function to make it move closer, and once its close enough (within the attack radius), we call the shoot_projectile() function and set the attack timer to the projectile’s cooldown (which we returned, remember?). One special edition here is that I wrote a couple lines of code to actively move the enemy away from the player (in a vector pointing opposite to the direction of the player) if the player’s shield has been activated. This gives the effect of a protective halo around the player, because even though we block the projectile attacks in our code, its good to provide visual confirmation too.

Conclusion

That’s a wrap for part 3! Now that we’ve made our enemies, the only thing really left to do is to refine our code and add some UI/UX elements, which we’ll cover in the next part. We MIGHT also implement a pathfinding algorithm for the enemy or make them smarter using AI, but the game should work perfectly fine without it. Anyway, thank you for making it to the end and see you next time :)