Chapter 8. Sprite Collision Detection: The Zombie Mob Game

We briefly touched upon the subject of collision detection in Chapter 7 when we needed to know when the flaming arrows were hitting the player and the dragon. In that chapter game, just one type of collision detection was used, between just one sprite and another (one-to-one). However, Pygame supports several types of collision detection techniques that we will be learning to use in this chapter. The subject of sprite groups will also become more important as you will see in the chapter example, called The Zombie Mob Game, which will use a large group of zombies versus the player for some fast-action gameplay. These are fairly advanced topics but all of the concepts hold each other up rather than stand on their own, so the code does get easier after a time.

In this exciting chapter you will learn how to:

  • Check for collisions between two sprites

  • Check for collisions between whole groups of sprites

  • Create an awesome game called The Zombie Mob Game

Examining the Zombie Mob Game

The Zombie Mob Game, shown in Figure 8.1, is a fast-paced game in which the player has to run away from the zombies while collecting food in order to survive. This type of gameplay helps to demonstrate collision testing quite well because so many sprites are involved in the game. The gameplay will be improved even further in the next chapter when custom levels are designed for the game while learning about arrays and tuples.

The Zombie Mob Game.

Figure 8.1. The Zombie Mob Game.

Collision Detection Techniques

Pygame supports several forms of collision detection that we can use for several different circumstances. Why do we need so many? Basically, for optimized code. Some forms of collision testing will involve just two sprites, while some test all of the sprites in an entire sprite group. It’s even possible to test two groups against each other and get a list of all affected sprites in each group! This is a particularly interesting technique for a game like The Zombie Mob Game where there are item sprites to be picked up, a player sprite, and a large group of zombie sprites.

Rectangle Collision Between Two Sprites

One-on-one collision testing between just two sprites (rather than testing within a whole sprite group) is done with the pygame.sprite.collide_rect() function. Two parameters are passed, and each must be derived from pygame.sprite.Sprite. More specifically, any object can be passed as a parameter as long as it has a Rect property called rect available. In the function itself, left.rect and right.rect are used for the collision test, so if your first “sprite” (or any other object) has a rect property, then it will technically work, and the same goes for the second parameter called right. The function just returns a bool value (True or False) as a result of the collision test. This simple function will be your workhorse for custom sprite collision testing!

Using our custom MySprite class as a basis for examples, here is a simple one:

first = MySprite("battleship.png", 250, 120, 1)
second = MySprite("rowboat.png", 32, 16, 1)
result = pygame.sprite.collide_rect( first, second )
if result:
    print_text(font, 0, 0, "What were you thinking!?")
    sys.exit()

There’s a variation of this function that we can also use for somewhat better results in some cases, depending on the sizes of the sprite images. The function is pygame.sprite.collide_rect_ratio(). The difference is, this function has an additional parameter—a float—where you can specify a percentage of the rectangle for the sprites to be used for collision. This is useful when there’s a lot of empty space around the edges of a sprite image, in which case you’ll want to make the rectangle smaller.

The syntax is a little strange, though, because the function actually creates an instance of a class with the reduction value, and then that result is passed the two sprite variable names as additional parameters.

pygame.sprite.collide_rect_ratio( 0.75 )( first, second )

Circle Collision Between Two Sprites

Circle collision is based on a radius value for each sprite. You can specify the radius yourself or let the pygame.sprite.collide_circle() function calculate the radius automatically. We might want to specify our own radius (as a new property of the sprite passed to this function) in order to fine-tune the collision results. If the radius property is not already there, then the function just calculates the radius based on the image size. The automatically created circle will not always produce very accurate collision results because the circle’s radius completely encompasses the rectangle (that is, the diagonals rather than the width or height).

if pygame.sprite.collide_circle( first, second ):
    print_text(font, 0, 0, "Ha, I caught you!")

A variation of this function is also available with a float modifier parameter called pygame.sprite.collide_circle_ratio().

pygame.sprite.collide_circle_ratio( 0.5 )( first, second )

Pixel-Perfect Masked Collision Between Two Sprites

The last collision testing function in pygame.sprite has the potential to be really awesome if used correctly! The function is pygame.sprite.collide_mask(), and receives two sprite variables as parameters, returning a bool.

if pygame.sprite.collide_mask( first, second ):
    print_text(font, 0, 0, "Argh, my pixels!")

Now, the awesome part is how this function works: if you supply a mask property in the sprite class, an image containing mask pixels for the sprite’s collidable pixels, then the function will use it. Otherwise, the function will generate this mask on its own—and that’s very, very bad. We definitely do not want a collision routine to mess with pixels every time it’s called! Imagine if you have just 10 sprites using this function, all colliding with each other—that’s 100 collision function calls, and 200 mask images being generated. So, this function has the potential to give really great collision results, but you simply must supply the mask image yourself!

To create a mask, look at the functions in the Surface module for reading and writing pixels. I’ll give you a few hints: Surface.lock(), Surface.unlock(), Surface.get_at(), and Surface.set_at(). It’s a lot of work, so unless your game really would benefit from this kind of precision, just use rectangular or circular collision instead!

Note

Pixel-Perfect Masked Collision Between Two Sprites

Go ahead and give masked collision detection a try, but I don’t recommend using it unless you have a slow-moving game where extreme precision is important. The other collision techniques work completely fine for 99 percent of the gameplay I’ve ever seen.

Rectangle Collision Between a Sprite and a Group

The first group collision function we’re going to study now is pygame.sprite.spritecollide(). This function is surprisingly easy to use considering how much work it does. In a single function call, all of the sprites in a group are tested against another single sprite for collision, and a list of the collided sprites is returned as a result! The first parameter is the single sprite, while the second is the group. The third is a bool that really has great potential! Passing True here will cause all collided sprites in the group to be removed! That’s a lot of hard work being done for us in a single function call. To manage the “damage,” all sprites removed from the group are returned in the list!

collide_list = pygame.sprite.spritecollide( arrow, flock_of_birds, False)

Now, there is a variation of this function, which is probably not a big surprise given what we’ve seen already! The variation is pygame.sprite.spritecollideany(), and is a faster version of the function. Rather than returning all of the sprites in a list, it just returns a bool when a collision occurs with any sprite in the group. So, as soon as a collision occurs, it returns immediately.

if pygame.sprite.spritecollideany( arrow, flock_of_birds ):
    print_text(font, 0, 0, "Nice shot, you got one!")

The only problem is, you will have no way of knowing which sprite in the group was hit, but depending on gameplay that may not matter. Let me explain how this is useful. Imagine you have a maze-style game where all the walls of the maze are stored in a sprite group. Now, any time the player sprite has a collision with one of the walls in that group, it doesn’t matter which wall, we just want to make the player stop moving. Presto, instant wall collision handling!

Note

Rectangle Collision Between a Sprite and a Group

There’s a mistake in the Pygame 1.9 docs related to the spritecollideany() function: the docs say the return value is a bool, but it is actually the sprite object in the group that collided with the other sprite passed to the function.

Rectangle Collision Between Two Groups

The last collision detection technique we’ll look at is pygame.sprite.groupcollide(), which tests for collisions between two sprite groups. This is a potentially very intensive process and should not be used lightly if there are a very large number of sprites in either group being passed to it. The return from this function is a dictionary containing key-value pairs. Every sprite in the first group is added to the dictionary. Then, every sprite from group two that collides is added to the entry for group one in the dictionary. Some items from group one may be empty, while some might have many sprites from group two. Two additional bool parameters specify whether sprites should be removed from group one and two when a collision occurs.

hit_list = pygame.sprite.groupcollide( bombs, cities, True, False )

The Zombie Mob Game

We’re going to use the information about collision detection now to make a game with a lot of sprites on the screen. The Zombie Mob Game pits the player against a mob of zombies. But, there’s no weapon! This player character is a helpless civilian without weapons, and the goal is to avoid the zombies while collecting food in order to have energy to keep running away. The energy level drops every time the player moves, and if the energy reaches zero then the player won’t be able to move any more and the zombies will have their favorite (and only) food for dinner—your brains.

Creating Your Own Module

Besides making the game using collision detection techniques, we’re going to explore modular programming at this point too because our library of code is getting kind of repetitive. We have the MySprite class, the print_text() function, and as you may recall, the useful Point class introduced back in Chapter 6—these will be used frequently. So, we’ll put them in a separate source code file for reuse. Python make this really easy to do, too! Just put your code in another file with a .py extension, and call it whatever you want. Then, in the program code where you want to use that helper code, add an import statement! I’m going to call the helper library file MyLibrary.py. So, in the game file we’ll add this line:

import MyLibrary

Well, in a manner of speaking. If you do it that way, then you have to add MyLibrary. (with a period) in front of every class name and function in MyLibrary.py to use it. Not a big deal, but I want the code to pretty much remain as it has in past chapters. So, we’ll use a variation of import that includes everything in the file into Python’s global namespace:

from MyLibrary import *

One more thing: Any time you need to reference something in MyLibrary, you’ll have to pass it as a parameter or create a local reference to the object. The screen variable, for example, is used for drawing. So, instead of passing it to every function that needs it, we can just call pygame.display.get_surface() to retrieve the existing surface. The print_text() function needs this line added, for example.

screen = pygame.display.get_surface()

Below is the source code for the MyLibrary.py file. Now, just go ahead and add any new functions or classes to this file and then copy the file to the folder where any of your Python/Pygame games are located, so that you can use it. Just note that we will not be including these classes and functions in any future examples, so take note of where they went!

# MyLibrary.py
import sys, time, random, math, pygame
from pygame.locals import *

# prints text using the supplied font
def print_text(font, x, y, text, color=(255,255,255)):
    imgText = font.render(text, True, color)
    screen = pygame.display.get_surface()
    screen.blit(imgText, (x,y))

# MySprite class extends pygame.sprite.Sprite
class MySprite(pygame.sprite.Sprite):

    def __init__(self):
        pygame.sprite.Sprite.__init__(self) #extend the base Sprite class
        self.master_image = None
        self.frame = 0
        self.old_frame = -1
        self.frame_width = 1
        self.frame_height = 1
        self.first_frame = 0
        self.last_frame = 0
        self.columns = 1
        self.last_time = 0

    #X property
    def _getx(self): return self.rect.x
    def _setx(self,value): self.rect.x = value
    X = property(_getx,_setx)

    #Y property
    def _gety(self): return self.rect.y
    def _sety(self,value): self.rect.y = value
    Y = property(_gety,_sety)

    #position property
    def _getpos(self): return self.rect.topleft
    def _setpos(self,pos): self.rect.topleft = pos
    position = property(_getpos,_setpos)

    def load(self, filename, width, height, columns):
        self.master_image = pygame.image.load(filename).convert_alpha()
        self.frame_width = width
        self.frame_height = height
        self.rect = Rect(0,0,width,height)
        self.columns = columns
        #try to auto-calculate total frames
        rect = self.master_image.get_rect()
        self.last_frame = (rect.width // width) * (rect.height // height) - 1

    def update(self, current_time, rate=30):
        #update animation frame number
        if current_time > self.last_time + rate:
            self.frame += 1
            if self.frame > self.last_frame:
                self.frame = self.first_frame
            self.last_time = current_time

        #build current frame only if it changed
        if self.frame != self.old_frame:
            frame_x = (self.frame % self.columns) * self.frame_width
            frame_y = (self.frame // self.columns) * self.frame_height
            rect = Rect(frame_x, frame_y, self.frame_width, self.frame_height)
            self.image = self.master_image.subsurface(rect)
            self.old_frame = self.frame

    def __str__(self):
        return str(self.frame) + "," + str(self.first_frame) + 
               "," + str(self.last_frame) + "," + str(self.frame_width) + 
               "," + str(self.frame_height) + "," + str(self.columns) + 
               "," + str(self.rect)

#Point class
class Point(object):
    def __init__(self, x, y):
        self.__x = x
        self.__y = y

    #X property
    def getx(self): return self.__x
    def setx(self, x): self.__x = x
    x = property(getx, setx)

    #Y property
    def gety(self): return self.__y
    def sety(self, y): self.__y = y
    y = property(gety, sety)

    def __str__(self):
        return "{X:" + "{:.0f}".format(self.__x) + 
            ",Y:" + "{:.0f}".format(self.__y) + "}"

Advanced Directional Animation

Our Zombie Mob Game uses some artwork to make it look really cool. I was going to make the zombies just as green circles and the player as a white circle, but that would not make it onto the cover of any game development magazines or the front page of any web logs, so this game will be using good artwork! The player character’s artwork is shown in Figure 8.2.

Sprite sheet of the animated walking player character.

Figure 8.2. Sprite sheet of the animated walking player character.

Note the specifications of this sprite sheet, since we’ll have to know this information for our source code. There are eight columns across, and eight rows, so that’s 64 total frames. You can’t tell from the figure, but by opening the bitmap file in a graphic editor to view it, you will note that the file dimensions are 768 × 768. That breaks down to a frame size of 96 × 96 pixels. But, we don’t really need all of these frames of animation. This is a great sprite sheet for a game that could use eight directions of movement: north, south, east, west, and all four diagonals. Our zombie game will just be using the four primary directions—but going to eight could be an upgrade to the game if you want to tackle it! Figure 8.3 shows the sprite sheet for the zombie. All of the zombie sprites will share this one bitmap file.

Sprite sheet of the animated walking zombie.

Figure 8.3. Sprite sheet of the animated walking zombie.

To make these sprites move in the four directions when there are eight animation sequences, we have to manually control the animation frame ranges. Table 8.1 shows the specifications for the animations. Since both the player and zombie sprite sheets have the same dimensions, this applies to both. While studying the table’s figures, use the figures showing the two sprite sheets as a reference. I’ve found it actually helps to count each frame in each row. Once you have counted the first few rows, you should notice a pattern emerge—based on the number of columns (eight). Since this is a pattern, we can use it to automatically calculate the range for each direction.

Table 8.1. Sprite Sheet Dimensions

Row

Description

Start Frame

End Frame

0

north

0

7

1

--

8

15

2

east

16

23

3

--

24

31

4

south

32

39

5

--

40

47

6

west

48

55

7

--

56

63

The direction value will be added to the sprite class as a new property. While we’re at it, we will also need a velocity property added to MySprite so that the sprite can be moved based on its direction. So, yes, we will need to open up MyLibrary and make an addition to the MySprite class. That’s what it’s there for, so don’t be afraid to modify it! Let’s do that right now while we’re thinking about it:

class MySprite(pygame.sprite.Sprite):
    def __init__(self):
        pygame.sprite.Sprite.__init__(self) #extend the base Sprite class
        self.master_image = None
        self.frame = 0
        self.old_frame = -1
        self.frame_width = 1
        self.frame_height = 1
        self.first_frame = 0
        self.last_frame = 0
        self.columns = 1
        self.last_time = 0
        self.direction = 0
        self.velocity = Point(0,0)

That’s all we have to do to add it as a global property. Oh, it’s not a real Python class property, but it will work fine this way, like the others. The only time I create a real property with a get() and set() pair (which are called the accessor/mutator methods, by the way), is when either one has to do some logic, as was the case with the X and Y properties.

Now, we have a MySprite.direction property available, so what we want to do is set the direction based on user input. When the player presses the Up key, we will set the direction to 0 (north). Likewise, we’ll do the same for the Right key (2, east), the Down key (4, south), and the Left key (6, west). The code below takes into account both the arrow keys and W-A-S-D keys commonly used for movement.

    if keys[K_UP] or keys[K_w]:      player.direction = 0
    elif keys[K_RIGHT] or keys[K_d]: player.direction = 2
    elif keys[K_DOWN] or keys[K_s]:  player.direction = 4
    elif keys[K_LEFT] or keys[K_a]:  player.direction = 6

The direction will then determine the frame range that will be used for animation. So, if you press the Up key, the range 0 to 7 will be used, and so on according to Table 8.1. The great thing about the direction property is that we can use it in a simple calculation to set the range. No if statement is needed!

    player.first_frame = player.direction * player.columns
    player.last_frame = player.first_frame + 8

We’ll want to be sure this code comes before player.update(), which is where animation is updated.

Colliding with Zombies

Our collision code in the game will involve two stages. First, we’ll use pygame.sprite.spritecollideany() to see if the player sprite touches any zombie sprite. If that comes back with a hit, then we’ll do a second collision test using pygame.sprite.collide_rect_ratio() and reduce the collision boxes by 50 percent for a more accurate result—which leads to better gameplay. The reason this second stage is needed is because the frames in the sprite sheets are quite large compared to the actual image pixels in each frame, so the collision needs to be tightened up a bit for better gameplay. A scratch sprite object called attacker is used to track when the player has been hit by a zombie. After the two-stage collision checks pass, then the player loses health, and the zombie is pushed back a little ways to give the player room to escape. Figure 8.4 shows the player about to be attacked!

The player is getting attacked by the zombies!

Figure 8.4. The player is getting attacked by the zombies!

Note

The player is getting attacked by the zombies!

Be careful when writing code that causes something to reverse direction (or any other state), because if it isn’t also moved out of that position (or state), then it will keep flip-flopping back and forth and seem to “wig out” on the screen. At worst, this can actually lock up the game.

    #check for collision with zombies
    attacker = None
    attacker = pygame.sprite.spritecollideany(player, zombie_group)
    if attacker != None:
        #we got a hit, now do a more precise check
        if pygame.sprite.collide_rect_ratio(0.5)(player,attacker):
            player_health -= 10
            if attacker.X < player.X:   attacker.X -= 10
            elif attacker.X > player.X: attacker.X += 10
        else:
            attacker = None

Getting Health

The health sprite is a little red cross on a white circle that the player can pick up to gain +30 health (of course you may change this if you want to make the game harder or easier!). The code to let the player pick up the health sprite looks like this. When the health sprite is picked up, then the player receives bonus health and it is moved to a new random location on the screen. Figure 8.5 shows the health sprite and the player needs it badly in this case!

That health pickup is really far away!

Figure 8.5. That health pickup is really far away!

    #check for collision with health
    if pygame.sprite.collide_rect_ratio(0.5)(player,health):
        player_health += 30
        if player_health > 100: player_health = 100
        health.X = random.randint(0,700)
        health.Y = random.randint(0,500)

Note

That health pickup is really far away!

The player can move just slightly faster than the zombies, which makes the game fun. If the player moves at the same or slower speed than the zombies, then the gameplay would be frustrating. Always give your player the edge over the bad guys so they’ll keep coming back to play. Frustrating the player is a sure-fire way to make them quit playing.

If the player gets attacked by the zombies too much and can’t get to the health powerup, then eventually the health bar runs out and the player dies. This marks the end of the game. There is no way to reset the game other than by running it again.

Oh no, the player has died!

Figure 8.6. Oh no, the player has died!

Game Source Code

Here, finally, is the complete source code for The Zombie Mob Game. It’s a rather short listing considering how much gameplay this game is packing! Thanks to the MyLibrary.py file, we’ve managed to store away the reusable code and tighten up our game’s main code listing.

# Zombie Mob Game
# Chapter 8
import itertools, sys, time, random, math, pygame
from pygame.locals import *
from MyLibrary import *

def calc_velocity(direction, vel=1.0):
    velocity = Point(0,0)
    if direction == 0: #north
        velocity.y = -vel
    elif direction == 2: #east
        velocity.x = vel
    elif direction == 4: #south
        velocity.y = vel
    elif direction == 6: #west
        velocity.x = -vel
    return velocity

def reverse_direction(sprite):
    if sprite.direction == 0:
        sprite.direction = 4
    elif sprite.direction == 2:
        sprite.direction = 6
    elif sprite.direction == 4:
        sprite.direction = 0
    elif sprite.direction == 6:
        sprite.direction = 2

#main program begins
pygame.init()
screen = pygame.display.set_mode((800,600))
pygame.display.set_caption("Collision Demo")
font = pygame.font.Font(None, 36)
timer = pygame.time.Clock()

#create sprite groups
player_group = pygame.sprite.Group()
zombie_group = pygame.sprite.Group()
health_group = pygame.sprite.Group()

#create the player sprite
player = MySprite()
player.load("farmer walk.png", 96, 96, 8)
player.position = 80, 80
player.direction = 4
player_group.add(player)

#create the zombie sprite
zombie_image = pygame.image.load("zombie walk.png").convert_alpha()
for n in range(0, 10):
    zombie = MySprite()
    zombie.load("zombie walk.png", 96, 96, 8)
    zombie.position = random.randint(0,700), random.randint(0,500)
    zombie.direction = random.randint(0,3) * 2
    zombie_group.add(zombie)

#create heath sprite
health = MySprite()
health.load("health.png", 32, 32, 1)
health.position = 400,300
health_group.add(health)

game_over = False
player_moving = False
player_health = 100

#repeating loop
while True:
    timer.tick(30)
    ticks = pygame.time.get_ticks()

    for event in pygame.event.get():
        if event.type == QUIT: sys.exit()
    keys = pygame.key.get_pressed()
    if keys[K_ESCAPE]: sys.exit()
    elif keys[K_UP] or keys[K_w]:
        player.direction = 0
        player_moving = True
    elif keys[K_RIGHT] or keys[K_d]:
        player.direction = 2
        player_moving = True
    elif keys[K_DOWN] or keys[K_s]:
        player.direction = 4
        player_moving = True
    elif keys[K_LEFT] or keys[K_a]:
        player.direction = 6
        player_moving = True
    else:
        player_moving = False

    #these things should not happen when the game is over
    if not game_over:
        #set animation frames based on player’s direction
        player.first_frame = player.direction * player.columns
        player.last_frame = player.first_frame + player.columns-1
        if player.frame < player.first_frame:
            player.frame = player.first_frame

        if not player_moving:
            #stop animating when player is not pressing a key
            player.frame = player.first_frame = player.last_frame
        else:
            #move player in direction
            player.velocity = calc_velocity(player.direction, 1.5)
            player.velocity.x *= 1.5
            player.velocity.y *= 1.5

        #update player sprite
        player_group.update(ticks, 50)

        #manually move the player
        if player_moving:
            player.X += player.velocity.x
            player.Y += player.velocity.y
            if player.X < 0: player.X = 0
            elif player.X > 700: player.X = 700
            if player.Y < 0: player.Y = 0
            elif player.Y > 500: player.Y = 500

        #update zombie sprites
        zombie_group.update(ticks, 50)

        #manually iterate through all the zombies
        for z in zombie_group:
            #set the zombie’s animation range
            z.first_frame = z.direction * z.columns
            z.last_frame = z.first_frame + z.columns-1
            if z.frame < z.first_frame:
                z.frame = z.first_frame
            z.velocity = calc_velocity(z.direction)

            #keep the zombie on the screen
            z.X += z.velocity.x
            z.Y += z.velocity.y
            if z.X < 0 or z.X > 700 or z.Y < 0 or z.Y > 500:
                reverse_direction(z)


        #check for collision with zombies
        attacker = None
        attacker = pygame.sprite.spritecollideany(player, zombie_group)
        if attacker != None:
            #we got a hit, now do a more precise check
            if pygame.sprite.collide_rect_ratio(0.5)(player,attacker):
                player_health -= 10
                if attacker.X < player.X:
                    attacker.X -= 10
                elif attacker.X > player.X:
                    attacker.X += 10
            else:
                attacker = None

        #update the health drop
        health_group.update(ticks, 50)

        #check for collision with health
        if pygame.sprite.collide_rect_ratio(0.5)(player,health):
            player_health += 30
            if player_health > 100: player_health = 100
            health.X = random.randint(0,700)
            health.Y = random.randint(0,500)

    #is player dead?
    if player_health <= 0:
        game_over = True

    #clear the screen
    screen.fill((50,50,100))

    #draw sprites
    health_group.draw(screen)
    zombie_group.draw(screen)
    player_group.draw(screen)

    #draw energy bar
    pygame.draw.rect(screen, (50,150,50,180), Rect(300,570,player_health*2,25))
    pygame.draw.rect(screen, (100,200,100,180), Rect(300,570,200,25), 2)

    if game_over:
        print_text(font, 300, 100, "G A M E   O V E R")

    pygame.display.update()

Summary

That concludes our experiments in surviving the apocalypse. Sprite collision detection was also somewhat important in this chapter as well. The Zombie Mob Game was a pretty good example of several types of collision in practice. As the game code demonstrated, the response to collision events is extremely important.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.226.185.87