K'tah
LAB15

ktah_scientist.png

Welcome

The zombie apocalypse has arrived. You can run, but you can’t escape. All you can do is run around until you get tired. It’s a sad situation, but it’s the first step in programming a realistic game.

What We Will Learn

Interactive gameplay Keyboard events Inheritance Collision detection Custom events

Activity

Make sure you are in your virtual environment for this course with Pygame installed. Create the file ~/cmsi1010/lab15/ktah.py. Prime it with the skeleton code for a game that we will call K'tah. It is going to be a shadow of the famous K'tah, which you can find on GitHub.

import pygame

pygame.init()
WIDTH, HEIGHT = 1024, 768
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("K'tah")
clock = pygame.time.Clock()


def draw_scene():
    screen.fill((0, 100, 0))
    pygame.display.flip()


while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            raise SystemExit
    clock.tick(60)
    draw_scene()

This is the basic shell of any Pygame application which uses animation. We saw this in the previous lab, but things don’t always stick until you’ve done them a few times, so take the time to review each line in the code to make sure you follow.

Now let’s get a “game” going. We’re going to have a player that is chased by four zombies. Before we set up the chasing part, let’s get the player and the zombies defined and on the screen.

Data Modeling

Here’s an idea that is so powerful in computer science, computational thinking, and data modeling: there are often things (in our case players and zombies), that are at once kind of the same and kind of different. They have some things in common but not everything. The parts that are the same are: they have a position and size and color and that they can move and be drawn. Whenever we see such a situation we see if we can get a handle the common attributes and make an abstraction for them. In our case, players and zombies are both kinds of agents. So let’s define a type for agents (that will be circles for now):

@dataclass
class Agent:
    x: int
    y: int
    radius: int
    speed: int
    color: tuple

    def draw(self):
        pygame.draw.circle(screen, self.color, (self.x, self.y), self.radius)

Remember to write from dataclasses import dataclass at the top of your file.

Next, we will define two kinds of agents: players and zombies. Players will have defaults for everything: positioned at the center of the canvas, have a radius of 20 pixels, move at 5 pixels per frame, and appear light blue. (We saw default attributes in the last lab.) Players will also have a superpower: they can teleport to another position. Add this dataclass:

@dataclass
class Player(Agent):
    x: int = WIDTH // 2
    y: int = HEIGHT // 2
    radius: int = 20
    speed: int = 5
    color: tuple = (200, 200, 255)

    def teleport(self, pos):
        self.x, self.y = pos

Zombies will not have defaults for x, y, radius, or speed. Those attributes will have to be set on creation, but we will allow defaults for the other three attributes:

@dataclass
class Zombie(Agent):
    speed: int = 2
    radius: int = 20
    color: tuple = (80, 255, 0)

Vocabulary Time! Agent is the supertype and Player and Zombie are its subtypes. This means every player IS-A agent and every zombie IS-A agent. You add supertypes to a type when you declare it—look at the definitions of Player and Zombie above and make sure you follow the syntax. When you make a type a subtype of an existing type, all the supertype’s attributes and methods are inherited by the subtype. It’s pretty convenient, and it makes sense too: since a player is an agent and a zombie is an agent, everything an agent can do, a player and zombie can too—by definition!

Exercise: It turns out that Agenthas a supertype itself, even though we didn’t specify it directly. Find out what it is and why it is so special.

The Game Objects

Now we can create a player and some zombies. We’ll put the zombies near the corners of the display, and set them up with different speeds, but none will be greater than the player’s speed, since we want to give the player a fighting chance:

player = Player()
zombies = [
    Zombie(x=20, y=20, speed=1.8),
    Zombie(x=WIDTH-20, y=20),
    Zombie(x=20, y=HEIGHT-20, speed=2.5),
    Zombie(x=WIDTH-20, y=HEIGHT-20, speed=0.9)]

These lines of code only create five objects, but to display the objects, we have to call their draw methods inside our game loop. Update draw_scene to:

def draw_scene():
    screen.fill((0, 100, 0))
    player.draw()
    for zombie in zombies:
        zombie.draw()
    clock.tick(60)
    pygame.display.flip()

Run the program now and make sure you see the five circles.

Exercise: Make sure you understand why we could write player.draw() and zombie.draw() even though neither class Player nor Zombie defined draw methods. Do you see now why we use the term inheritance in the context of supertype-subtype relationships?

Movement

In our game, the player moves toward the mouse and the zombies move toward the player. But wait! Can we generalize this? Both players and agent move toward...something! So let’s create a move_towards method in the common superclass. Add this code to Agent:

    def move_towards(self, target):
        dx = target[0] - self.x
        dy = target[1] - self.y
        distance = math.hypot(dx, dy)
        if distance > 3.0:
            # Allow three pixels of leeway to avoid jittering
            self.x += (dx / distance) * self.speed
            self.y += (dy / distance) * self.speed
OMG MATH!

Don’t be scared. We’ll explain in class.

Now just add this code at the very beginning of draw_scene:

    player.move_towards(pygame.mouse.get_pos())
    for zombie in zombies:
        zombie.move_towards((player.x, player.y))

Run the program again. You should see the player circle move toward the mouse and the zombie circles move toward the player circle.

Exercise: Where did the pygame.mouse.get_pos() come from? It turns out get_pos is one of many things defined within the module pygame.mouse. Browse the Pygame mouse module documentation to find out what else you can do with the mouse.

We’ve made progress, but we don’t have a game yet. If the zombies crash into the player, nothing happens. Let’s fix that.

Collision Detection

We need to detect when the player and a zombie collide. We can do this by checking the distance between the player and each zombie. If the distance is less than the sum of their radii, then they have collided. Collision can in theory be computed for any agent, so let’s add a method to the Agent class:

    def is_collided_with(self, other):
        distance = math.hypot(self.x - other.x, self.y - other.y)
        return distance < (self.radius + other.radius)
Breaking this down

We crafted this method to be both readable and flexible. We call it as a1.is_collided_with(a2), which is True if and only if agent a1 has collided with a2, namely that their circles overlap.

How this code actually determines “overlap” will be discussed in class.

Now we are really close to implementing the important part of the game, which is to make sure a player does not get caught by (collides with) any of the zombies. Continuing with our powerful ideas of implementing small bits of functionality with great function (or method) names, we realize that “getting caught” is something that applies to a player, so let’s add this method to the player class:

    def is_caught_by_any_of(self, zombies):
        for zombie in zombies:
            if self.is_collided_with(zombie):
                return True
        return False
Why was that method so long?

When learning how to program, it’s common to see lots of for-loops and if-statements. The code above is direct and readable. With very little exposure to, and training in, Python, it just makes sense. But yes, there are more concise ways to do these kinds of things. The entire body of this function can be the one line:

  return any(self.is_collided_with(zombie) for zombie in zombies)

Before you use this kind of code in your projects, make sure you understand what it does.

Back in draw_scene, let’s make it so the animation “stops” when there’s a collision. By stopping we simply mean there’s no more movement. Add this to the beginning of draw_scene:

    if player.is_caught_by_any_of(zombies):
        return

Now run the program and let yourself get caught. Everything just stops. Perhaps that is good enough? but no, we have much more to learn.

Superpower #1: Teleportation

The first enhancement to the game is a relatively easy one. If you click anywhere in the window the player immediately teleports to the click position.

How to think about it: Pygame sends us events. We know about the MOUSEBUTTONDOWN event from earlier labs. We just have to handle it by teleporting the player to the current mouse position. We already defined the teleport method, so we just need to call it in the event loop. We add this code (after the full handler for the QUIT event):

        if event.type == pygame.MOUSEBUTTONDOWN:
            player.teleport(event.pos)

Superpower #2: Freezing the Zombies

Pressing the f key will “freeze” the zombies for 5 seconds. This allows the player a short amount of time to move farther away.

How to think about it: We need a variable frozen, initially false. If true, the zombies should not move. When the user press the f-key, detected through the Pygame event KEYDOWN with event.key == K_f, we set frozen to true only if is not already true! If we do set it, we will create a once-only-timer that will expire in five seconds, and send a custom event which we’ll call UNFREEZE. Handling this event will set frozen back to False (enabling the zombies to automatically move again).

In code:

Try it!

Custom Events

We can create our own events in Pygame. We just have to give them event identifiers starting at USEREVENT+1. You can find details in the Pygame event documentation page.

Superpower #3: The Scarecrow

Pressing the s key will set a scarecrow at the current mouse position, causing the zombies, for the next 5 seconds, to not chase the player but to move toward the scarecrow. This also gives the player a short breather to get away.

How to think about it: Create a variable called scarecrow set to the position of the scarecrow if present or None if not. When the user presses the s-key, we set the scarecrow to the current mouse position and start a timer that will remove it after 5 seconds. In the draw_scene method, if there is a scarecrow, we draw it and move the zombies toward it instead of toward the player.

In-Game Text

This has been a long lab, so we’ll do just one more thing: write the words “GAME OVER” in red text when the player is caught. To do this, (1) add the import line import pygame.freetype to your import section, (2) under your assignment to the clock variable, create a font object: font = pygame.freetype.SysFont('sans', 100), and change the top of the draw_scene function to display the text:

    if player.is_caught_by_any_of(zombies):
        font.render_to(screen, (20, 20), "GAME OVER", (255, 0, 0))
        pygame.display.flip()
        return

Finally, on to the challenges.

Challenges

Now it’s your turn. There are so many ways to build on the lovely little game stub we just made together. Here are just a few ideas. Use your creativity to come up with additional extensions.

Feel free to bring in new ideas of your own.

Further Study

Now that we’ve officially studied inheritance, Chapter 18 of Think Python, 2nd edition has a short treatment. You can also read about inheritance in the official Python tutorial.

Inheritance, which comes from creating hierarchies, or ontologies, of classes can be sometimes very useful, but be careful not to overuse it. Please read Clay Shirky’s remarkable essay Ontology is Overrated.

The “game” in this lab barely meets the requirements of being a game. There is so much more to the field of game design! You might like to read The Art of Game Design by Jesse Schell to learn more.

You may also be interested in some K'Tah-style projects made by students in the past, when this course used the much more game-friendly and game-accessible JavaScript language. Out of the nearly 100 past submissions, here are four fun ones:

Summary

We’ve covered:

  • Tracking mouse movement
  • math.hypot
  • Collision detection
  • Inheritance
  • Interesting game play

Recall Practice

Here are some questions useful for your spaced repetition learning. Many of the answers are not found on this page. Some will have popped up in lecture. Others will require you to do your own research.

  1. What are supertypes and subtypes in Python?
    A supertype is a general class that defines common attributes and methods, while a subtype is a more specific class that inherits from the supertype and may add or override attributes and methods.
  2. What is the computer science term that refers to the way subtypes have the same attributes and methods of their supertypes?
    Inheritance.
  3. In a game with multiple kinds of agents, what kind of attributes are you likely to see in an agent supertype?
    Position, size, speed, and color.
  4. Which Pygame event do you look for to detect a key press?
    You look for the KEYDOWN event.
  5. What is the most important attribute of the keydown event in Pygame?
    The attribute key that indicates which key was pressed.
  6. How do you get the current mouse position?
    You call pygame.mouse.get_pos().
  7. How do you detect a collision between two circles?
    You compute the distance between their centers and check if it is less than the sum of their radii.
  8. How do you get the distance between two points (x1,y1) and (x2,y2) in Python?
    math.hypot(x2 - x1, y2 - y1).
  9. How do you define custom events in Pygame? Given an example.
    You use the pygame.USEREVENT constant and add an integer to it to create a unique event identifier. For example, STOP = pygame.USEREVENT + 1.
  10. How do you send the event STOP every 10 seconds in Pygame?
    pygame.time.set_timer(STOP, 10000).
  11. How do you send the event STOP only once after 10 seconds in Pygame?
    pygame.time.set_timer(STOP, 10000, loops=1).
  12. What module do you import to be able to render text in Pygame?
    You import the pygame.freetype module.
  13. What line of code renders the text “Hello world” to the screen at position (100, 100) in red?
    font.render_to(screen, (100, 100), "Hello world", (255, 0, 0)).