Skip to main content

Command Palette

Search for a command to run...

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

The player, hazards, and sprite animations

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

An aspiring developer

Now that we have our map and project setup, we can move on to coding our main character. Since this is a Geronimo Stilton inspired game, I chose a mouse as the sprite (unique, I know). I found this sprite for $2 on itch.io, I’ll link it below, but you can use - or better, make - your own sprite sheet. Before we begin figuring out how to make the player, lets start by outlining what exactly we want it to do :

  • Properties : The player will have attributes like number of lives, health, coins and points, as well as the magic objects it has collected so far. Physically, it will be represented by an image, or rather a sequence of images called a sprite sheet which we will discuss later on.

  • Movement : The player can move in all 4 directions, but not on top of objects in our ‘Boundaries’ layer, for example trees and rocks. The arrow keys will trigger these movements.

  • Attack : The player will be able to attack the enemies when we press the ‘a’ key.

  • Spells : The shield spell will automatically enable once the player as gathered 20 coins. Once it is activated, it will block out all enemies within the player’s radius and last for 8 seconds before the coin count resets. The other spell is the reveal spell, which can be cast by the player by pressing the ‘r’ key. It will last for 12 seconds, and will recharge after 24 seconds. The reveal spell, as the name suggests, will reveal the 5 hidden magic objects to the player.

  • Collision : Collision detection will depend on the type of object the player collides with. For example, if the player collides with a gem, it will collect it and the coin count will increase. Collisions with a hazard will decrease the player’s lifespan, but there are no health-related repercussions to colliding with a boundary, it’ll just have to go around it.

Sprite animations

Let’s start with the easiest animation first, the hazards. I only have one type of hazard in my game, but you can use the same code for multiple. I don’t have a sprite sheet for hazards, but rather 4 consecutive images in the same folder that I’ll loop over to create animation. Let’s start adding the animation function to our Hazard class now :

def animate(self):
        self.current_sprite += 0.15
        if self.current_sprite >= 3:
            self.current_sprite = 0
        self.image = image.load(f'levels//level_1//tilesets//hazards//plant_{int(self.current_sprite)}.png') #simple sprite animation. this is why i made a class for hazards and didnt just use tile
        self.mask = mask.from_surface(self.image)

This function uses the class variable current_sprite to keep track of the animation frame we are currently on. Each time our game updates, which is at 60 frames per second, our current_sprite increases by 0.15. Now the reason we increase it by that and not 1 is so that the animation isn’t too fast. Once all the images in the sequence have loaded, we reset current_sprite to 0 to repeat the animation. My hazard images are named ‘plant 0’ … ‘plant 3’ so I set self.image to the directory they’re in, changing the image number according to current_sprite each time.

Moving on to animating some actual sprite sheets, lets animate the player. We’ve already decided on what the player can do, the animation just has to reflect that. To recap, the player can move or remain idle in all 4 directions + it can attack.

Let’s start by writing a function to load the sprite sheet :

def load_sprite_sheet(self, path, frame_width, frame_height):
        sheet = image.load(path).convert_alpha()
        sw, sh = sheet.get_size()
        #Note: the image you're using should have proper sprites of the size frame_width * frame_height
        #at proper space increments.
        sprites = []
        for y in range(0, sh, frame_height):
            for x in range(0, sw, frame_width):
                frame = Surface((frame_width, frame_height), SRCALPHA)
                frame.blit(sheet, (0, 0), (x, y, frame_width, frame_height)) #blit different parts of the spritesheet 
                sprites.append(frame)

        return sprites

This function basically returns a list containing images from a given sprite sheet, by cropping parts of it onto different transparent surfaces of frame_width and frame_height. Since our sprite is 64×64 px, these 2 variables will almost always be those dimensions but I haven’t hardcoded it to be more flexible.

We will then use this function to load our movement (which I call running in my code) and attack spreadsheets in the function below :

 def load_images(self):
        states = {}
        run_right = self.load_sprite_sheet('assets//sprites//level_1_mc//running.png', 64, 64)
        run_left = [transform.flip(img, True, False) for img in run_right]
        run_up = [transform.rotate(img, 90) for img in run_right]
        run_down = [transform.rotate(img, -90) for img in run_right]
        #...

‘Running.png’ is a sprite sheet of the player running in the RIGHT direction. Notice that we can get a sprite sheet of the player running left just by flipping the images horizontally? Well that’s what we’re doing in line 4! Since run_right contains a list of consecutive images of the player running right, we use array comprehension to create a new array run_left which stores image in run_right after flipping it horizontally. The issue at hand here, is that in the asset pack I used, there were no sprite sheets for upward and downward movement (front and back profiles of the MC). So I came up with a very wonky solution, which was to just rotate the player 90 degrees to make it look like it was going up, and -90 degrees to make it run down.

The last thing left to do in this function is to add all these lists to our dictionary states. To make directions easier to set, I represent them with their initial, as in r for right, l for left etc. We can then define states for each of the directions as states[‘running_r’] = run_right and changing the key and value for each direction. Once you have movement down, you can do the exact same for attack in all direction.

Now, before we get to writing our animate() function, we need to know when to animate the player. Let’s write a function to get input from the user to determine this :

    def input(self):
        self.animate = False
        keys = key.get_pressed()
        if keys[K_RIGHT]: 
            self.direction.x = 1
            self.current_state = "r"
            self.animate = True

        elif keys[K_LEFT]:
            self.direction.x = -1 
            self.current_state = "l"
            self.animate = True
        else:
            self.direction.x = 0

        if keys[K_UP]:
            self.direction.y = -1 
            self.current_state = "u"
            self.animate = True
        elif keys[K_DOWN]:
            self.direction.y = 1 
            self.current_state = "d"
            self.animate = True
        else:
            self.direction.y = 0

        if keys[K_a]:
            self.is_attacking = True
            self.weapon = "attack"
            self.attack_time = time.get_ticks()

        if keys[K_c] and 'special' in self.spells: #unlock special weapon if the player has collected at least 30 coins. only this special weapon can kill the ending fairies
            self.is_attacking = True
            self.weapon = "special"
            self.attack_time = time.get_ticks()

This is a pretty simple function, it detects which key has been pressed and sets the current_direction to it. If we press the ‘a’ key, is_attacking becomes True . We can now animate using all these functions.

def animate_player(self):
        direction = self.current_state

        if self.animate:
            if self.is_attacking:
                animation = self.sprite[self.weapon+"_" + direction]
                self.current_sprite += 0.15
                if self.current_sprite >= len(animation):
                    self.current_sprite = 0
                self.image = animation[int(self.current_sprite)]
            else:
                animation = self.sprite['running_' + direction]
                self.current_sprite += 0.15
                if self.current_sprite >= len(animation):
                    self.current_sprite = 0
                self.image = animation[int(self.current_sprite)]
        else:
            if self.is_attacking:
                animation = self.sprite[self.weapon+"_" + direction]
                self.current_sprite += 0.1
                if self.current_sprite >= len(animation):
                    self.current_sprite = 0
                self.image = animation[int(self.current_sprite)]
            else:
                self.image = self.sprite['idle_' + direction]

        self.mask = mask.from_surface(self.image)
        self.rect = self.image.get_rect(center=self.rect.center)

This is a variation on our initial animate function that we used for hazards. Before you do this, you’ll need to call load_images() in your constructor and store the dictionary states in a class variable. I call this variable sprite.

Remember how we created states? We set an action + ‘_’ + r, l, u or d as the key and the corresponding list of images as the value. Well, that dictionary is precisely what we use now to load the correct set of images and run the animation.

For enemies, use the same code to load the sprite sheets and then the animation function from hazards (since enemies don’t require user input to determine their direction like the player). Later, when we code our enemy class, we’ll modify the function slightly to make sure the enemies always face the player.

The player

To construct the player class, we’ll start by translating some of the things we wrote above, about what we wanted the player to do, into code. Beginning with the constructor :

class Player(sprite.Sprite):
    def __init__(self, pos, groups, obstacles):
        super().__init__(groups)
        self.sprite = self.load_images()
        self.current_sprite = 0.0
        self.animate = False
        self.current_state = "r"
        self.image = self.sprite["idle_"+self.current_state]
        self.rect = self.image.get_rect(topleft = pos)
        self.direction = math.Vector2() #x and y position of player as a vector. keyboard input will change these values
        self.obstacles = obstacles
        self.mask = mask.from_surface(self.image)
        self.coins = 0
        self.points = 0
        self.is_attacking = False
        self.attack_time = None
        self.weapon = "attack"
        self.current_spell = None
        #UI healthbar
        self.condition = {'health' : 100, 'attack' : 10, 'spells' : 1, 'weapons' : 1, 'speed' : 7, 'lives' : 3}
        self.health = self.condition['health']
        self.spells = ['regular']
        self.speed = self.condition['speed']
        self.lives = self.condition['lives']
        self.magic_objs = []
        self.reveal_timer = 0

So… that’s a lot of variables, but we’ll use them all some way or the other I promise. Moving on to collision detection :

      def collision(self,dir, old_rect):  
        if dir == 'horizontal':
            for sprite in self.obstacles:
                if self.mask.overlap(sprite.mask, (sprite.rect.x - self.rect.x, sprite.rect.y - self.rect.y)):
                    self.rect.x = old_rect.x #undo the move
                    if sprite.sprite_type == 'collectible':
                        sprite.kill() 
                        self.coins += 1
                        self.points += 1
                    if sprite.sprite_type == 'magic_obj':
                        sprite.power_up(self)
                        sprite.kill() # i realized that if i killed the obj to remove it from the screen, that would mean removing it from all groups, which would mean that it didnt get updated so the cooldown wouldnt work
                                        #instead i add it to a group call collected and update that group in my level class
                        self.collected.add(sprite)
                        self.magic_objs.append(sprite.magic_type)
                        self.points += 5
                    if sprite.sprite_type == "hazard" and self.current_spell!='shield_circle':
                        time.wait(400)
                        self.health -= 30

collision() takes in 2 parameters, dir , which can be either horizontal or vertical and old_rect, which stores the old position of the player so that we can undo a move. First, we check for horizontal collisions, then vertical so we know whether to undo the move in the x direction or the y. We start with looping over all the obstacles, and checking for collisions with each one of them. Let me break down the line that actually does the check :

if self.mask.overlap(sprite.mask, (sprite.rect.x - self.rect.x, sprite.rect.y - self.rect.y))

So what I’m doing here, is basically checking if there is any overlap between the masks of the obstacle sprite and the player sprite. Remember from part 1 how I created a variable self.mask in the class template for Tile, which we used as a base for all classes? Well what that mask is, is a precise pixel by pixel outline of the image we use as a sprite. Mask collisions are much more precise than the usual rect collisions which you may have seen, thus to make things smoother, I’m using those here.

The rest is pretty straightforward, if we collide with an obstacle of the type ‘Collectible’, we remove it from the screen (to show that it’s been collected of course), by ‘killing’ it. Then we increase our coin count and points by one. Collisions with magic objects and hazards do exactly what the code implies.
Also, ignore this part in the third to last line for now : self.current_spell!='shield_circle'

You can now copy-paste this block and make dir == ‘vertical’ instead. Okay so now we have - collisions, animations and input down. Let’s tackle movement next.

def move(self):
        old_rect = self.rect.copy() #store the prev positions incase i need to undo a move in collision
        #When we press both up/down and right/left keys together, the player moves significantly faster. 
        #Here, I check if the vector has a magnitude, which is possible if there is movement in both the axis. 
        #If there is, I normalize the vector to have a magnitude of 1 so that speed in that direction stays the same as the speed moving only horizontally or vertically
        if self.direction.magnitude() != 0: 
            self.direction = self.direction.normalize()
        self.rect.x += self.direction.x * self.speed
        self.collision("horizontal", old_rect) #check for horizontal collisions when moving on the x axis so we dont accidently reverse the y move
        self.rect.y += self.direction.y * self.speed
        self.collision("vertical", old_rect) #check for vertical collisions when moving on the y axis so we dont accidently reverse the x move

Remember to call both move(), animate_player() and input() in your update() method.

We can now work on our spell function.

def cast_spell(self):
        keys = key.get_pressed()
        if keys[K_r] and self.current_spell == None and self.can_cast_again:
            self.current_spell = "reveal"
            self.cast_time = time.get_ticks()
         #What this segment does, is adds the hidden magic objects 
         #to self.visible, which makes them show up on screen.
            if hasattr(self, 'hidden'):
                for spr in self.hidden:
                    self.groups()[0].add(spr)
                    self.obstacles.add(spr)
            self.dark_overlay = pygame.Surface(display.get_surface().get_size(), pygame.SRCALPHA)

        if self.coins >= 25 and self.current_spell == None:
            self.cast_time = time.get_ticks()
            self.current_spell = 'shield_circle'
            shield_img = transform.scale(image.load('assets//sprites//level_1_mc//shield_circle.png'), (100,100))
            self.shield = Tile((self.rect.centerx, self.rect.centery), [self.groups()[0], self.obstacles], 'protection', shield_img) 

        #self.groups()[0] is just visible sprites. i add it to visible sprites when its active and remove it by killing it when not

This is the implementation of the spells part of the player we outlined at the beginning. We check if the player is currently under any spell, and if the cooldown for the last one has worn off (self.can_cast_again). Then, if the play has pressed the ‘r’ key, we activate the reveal spell and save the time in self.cast_time (so that we can calculate how much time has passed since we cast it, and de-activate it accordingly).

If the player has collected more than 25 coins, we activate the shield spell and draw a shield around the player.

self.dark_overlay is a transparent-y black surface the size of the screen.
We can’t actually draw anything on screen through this function, but we can access the class variables and draw them in our Level class’ draw function, as such :

if hasattr(player, 'dark_overlay') and player.dark_overlay != None:
   player.dark_overlay.fill((0, 0, 0, 180))
   draw.circle(player.dark_overlay, (0, 0, 0, 0), player.rect.center- self.offset, 64) #circling the player and the objects with a transparent circle to give a flashlight effect
   for obj in player.hidden:
       draw.circle(player.dark_overlay, (0, 0, 0, 0), obj.rect.center - self.offset, 64) 
   self.screen.blit(player.dark_overlay, (0, 0))

Finally, for now, we can add some stuff to our player’s update function to deal with spell de-activation.

        if self.health <= 0:
            self.lives -= 1
            self.health = 100

        self.cast_spell()

        if self.current_spell == 'shield_circle' and hasattr(self, 'shield'):
            self.shield.rect.center = self.rect.center

        if self.current_spell:
            if time.get_ticks() - self.cast_time >= protection[current_level][self.current_spell]['lasts_for']:
                if self.current_spell == 'shield_circle': #once the shield circle is over i kill the shield
                    self.shield.kill() #remove the shield from the screen.
                elif self.current_spell == 'reveal':
                    self.cast_time = time.get_ticks()
                    self.can_cast_again = False
                    for spr in self.hidden:
                        self.groups()[0].remove(spr) # remove trhe objects from sight
                        self.obstacles.remove(spr)
                    self.dark_overlay = None

                self.current_spell = None
                self.coins = 0 #resets the coin count each time so i can activate the shield again and again when the criteria is met
                print('over') #just print debugging 


        if time.get_ticks() - self.cast_time >= protection[current_level]['reveal']['cooldown']:
            self.can_cast_again = True #after 24 seconds

Conclusion

And that’s it for part 2! There is a lot you can customize here, from spells to movement and even attack logic, so don’t be afraid to tweak and play around :)

Main character sprite sheet : https://14collective.itch.io/fantasy-mice