Class 30: Object-oriented Programming II

Objectives for today

Why Object-Oriented Programming (OOP) and games

Recall that we discussed two potential benefits for OOP:

  1. “Encapsulating” all of the complexity of specific data type, including any data and associated operations on that data
  2. Facilitating code reuse through shared interfaces and inheritance

Both of those benefits are very relevant for games. We often have many entities with complex data and behavior. We don’t want the infrastructure that manages time in the game, the screen, etc. to need “to know” about the details of all of those entities (such as how they are rendered to the screen), instead the entity objects can implement a common interface that encapsulates or “abstracts away” those details. Many of the entities will have similar features, e.g., a position, that can be implemented in shared base classes to facilitate code reuse (i.e., reduce code duplication).

Nouns and verbs?

Imagine you were going to create a dodgeball-type game where the player had to move around to avoid a set a moving obstacles bouncing around inside a room. Let’s brainstorm some of the game functionality:

What do you notice about the blue and red words? The red words, or nouns, are the data in our game and the blue words, or verbs, are the actions we to apply to those nouns. The former often maps to classes (or their attributes) and the latter to methods. In this example, we could create classes for the Player and Obstacle entities in our game, and methods to move those objects, check if they collide, etc.

Now that we have defined our classes, we start to think about what kind of data is associated with each of our classes. We notice in the first bullet there is another noun, “position”, which is described as belonging to a player. That suggests it might be an attribute of player. And as we think through what we need to implement our game, we will likely need to keep track of each entities’ position, size/shape and in the case of the obstacles their velocity/heading.

Interlude: Basic game loop with PyGame

In class and for our upcoming lab we will use the PyGame library to implement simple 2-D games. PyGame provides tools for managing time, representing shapes (e.g., rectangles), obtaining user inputs, rendering to the screen and more. As with many of the libraries we have used this semester we will only scratch the surface of this module. I encourage you to check out the documentation to learn more about PyGame.

To use PyGame you will need to install the package with the Thonny “Tools -> Manage Packages” command. Enter pygame into the search box, click “Find package from PyPI” then “Install”.

The heart of our games (and many others) is the game loop. It has three main steps:

  1. Poll for and handle user events, e.g. key presses
  2. Update games elements, e.g., positions, velocities, etc.
  3. Draw game elements

Since these steps are common to many games, we want to separate those common aspects from the specific details of our games. We will implement our game entities as objects with methods for creation, update and rendering that can be used by the code in the game loop. Notice that our game objects change extensively, but the game loop remains similar across our different examples.

A simple “game” using objects

As a starting point, we are going to implement an animation where a box moves around the screen bouncing off the walls. We will implement the box as an object (instance) of the Box class. The Box class will maintain the position and size of the box using a pygame.Rect object. By doing so we can take advantage of PyGame’s existing code for rendering rectangles, checking if two rectangles overlap, and more. Here is an implementation of that class. Check out the entire game program to see this class in context (along with additional comments, docstrings, etc.)

class Box():
    def __init__(self):
        self.rect = pygame.Rect(0, 200, 50, 50)
        self.velocity = [80, 80]
        
    def update(self, dt):
        self.rect.x += self.velocity[0]*dt
        self.rect.y += self.velocity[1]*dt
        
        # Use the relevant attributes to check if rectangle has hit the edges
        # of the screen (0,0 is the upper left corner)
        if (self.rect.left < 0 or self.rect.right > SCREEN_WIDTH):
            self.velocity[0] *= -1
        if (self.rect.top < 0 or self.rect.bottom > SCREEN_HEIGHT):
            self.velocity[1] *= -1

Notice that the update method encapsulates all the operations for determining the new position of of the box after some time-step dt. The caller does not need to know how the Box maintains its state internally, only the interface for performing the update.

In contrast, if you check out the game loop, you see that the rendering code accesses the box’s rectangle attributes and needs to know the color. That operation is a similarly good candidate for abstraction as a method. Why? If we wanted to change to a different underlying shape, or data structure, we shouldn’t need to change our game loop. The game loop only needs to know that we want to render a Box, not how we do so…

That is the following line

# Specify color as a RGB tuple
pygame.draw.rect(screen, (0, 0, 255), box.rect)

could become a render method:

def render(self, screen):
    pygame.draw.rect(screen, (0, 0, 255), self.rect)

the key difference is that we now access rect as an attribute of self. With that change, the game loop is not dependent on the internal implementation of Box, instead it just interacts with that entity via methods. We have successfully “abstracted” those operations (creation, update, rendering).

Extending the game via inheritance

We now want to add a player, represented by a red square that we can move around the screen trying to avoid multiple blue boxes. An initial implementation might look something like:

class Player():
    def __init__(self):
        self.rect = pygame.Rect(0, 0, 50, 50)
        
    def render(self, screen):
        pygame.draw.rect(screen, (255, 0, 0), self.rect)

We notice that there are many similarities between Player and Box, and specifically Player is a red box without velocity. A better description might be that Player is a red box that starts at 0,0 and Obstacle is a blue Box with velocity. As we saw last time we can implement that “is” relationship via inheritance:

class Box():
    def __init__(self, x, y, width, height, color):
        self.rect = pygame.Rect(x, y, width, height)
        self.color = color
    
    def render(self, screen):
        pygame.draw.rect(screen, self.color, self.rect)

class Obstacle(Box):
    def __init__(self):
        super().__init__(0, 200, 50, 50, (0, 0, 255))
        self.velocity = [80,80]
        
    def update(self, dt):
        ...

class Player(Box):
    def __init__(self):
        super().__init__(0, 0, 50, 50, (255, 0, 0))        

Here we create a common base class, Box, with rect and color attributes and a suitable render method and then two derived classes that customize the initialization of those objects and in the case of Obstacle, add a velocity attribute and update methods. By taking advantage of inheritance we avoid duplicating code in the Obstacle and in the Player!

When we create multiple obstacles, we want them to start in random locations on the screen and with random velocities (not just in the same spot). Where could we make that change? The Obstacle __init__ method is a natural place to perform such initialization. By implementing that code in the initializer, nothing about the game loop changes, it still invokes Obstacle() to create obstacles.

Handling player input and the game end

Check out the full game program. We have extended the game loop to handle user inputs. The different arrow keys shift the position of the player. We added a pair of “shift” methods to Player to implement those shifts. While we could have updated the underlying rect attribute directly, doing so would have coupled the game loop to that implementation. If we changed the way we stored the position and size of the player, i.e., replaced the rect attribute with some other data structure, we would need to update the game loop as well. By providing an “abstract” interface, any object that provides the shift_x, shift_y and render methods could be used as the player.

We want the game to end if the player and an obstacle collide (that is the goal is to avoid the obstacles). This is a very common operation in games and so PyGame provides a colliderect method that returns True if two rectangles overlap. How and where would you use colliderect? A natural approach is to implement a collide method in the Box class that wraps colliderect, e.g.

def collide(self, other):
    return self.rect.colliderect(other.rect)

That method is inherited by both Player and Obstacle and either can be used as the other argument, thus we could invoke player.collide(obstacle) or vice-versa. As with update and render, the collide method separates the interface from the implementation.

Credits: Adapted from Philip Caplan.