Class 15

Object-oriented Programming (OOP), cont.

Objectives for today

  • Use the PyGame library
  • Apply OOP in the context of a game

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:

  • Move the player (i.e., change the player’s position)
  • Move the obstacles, including bouncing off the walls
  • Check if a player and obstacle collided

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 instance variables/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 instance variable 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 assignment 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”. As heads up, the first time you run a program that imports pygame will be slow as the library sets itself up; subsequent uses will be much faster.

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. As class proceeds today, 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.) and the more complex version we will ultimately work towards.

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 instance variables 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 instance variables 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 in Box and corresponding call to that method in the game loop:

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

the key difference is that we now access rect as an instance variable 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 a” 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 instance variables 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 instance variable 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.

Interlude: Inheritance vs. composition

We just made a subtle design choice that merits more discussion. Why did we make the rectangle an instance variable, termed “composition”, but implemented Player and Obstacle via inheritance? Couldn’t we have implemented Box to derive from pygame.Rect? Possibly, but I think we would have found it awkward to do so. Like many of our design choices, there is not necessarily a “right” answer. Instead we are optimizing for understandability and maintainability (i.e., ability to make changes and improvements in the future).

Here is one way to think about the decision to use inheritance or not: Do the two types have a “is a” or “has a” relationship, i.e., if you were describing their relationship in plain language which of those phrases would you use? The former is consistent with inheritance, the latter with composition. In our game we would naturally say a Player “is a” Box or an Obstacle “is a” Box, and thus deriving Obstacle and Player from Box makes sense. But in PyGame, a Rect in an object for storing the coordinates associated with a rectangle, i.e., position and a size. It would be more natural to say a Box “has a” position and size, than a Box “is a” position and size. Thus it makes more sense to have the rectangle has an instance variable/attribute of Box than as a base class.

Handling player input

To enable the player to move we need to “listen” input events and update our game entities accordingly. Specifically we can check for KEYDOWN events and update the position based on which key was pressed. We add the following to our game loop:

event = pygame.event.poll()
if event.type == pygame.QUIT:
    break

if event.type == pygame.KEYDOWN:
    if (event.key == pygame.K_RIGHT):
        # Handle right
    elif (event.key == pygame.K_LEFT):
        # Handle left
    elif (event.key == pygame.K_UP):
        # Handle up
    elif (event.key == pygame.K_DOWN):
        # Handle down

What do we do to “Handle right”? We want to move the player to the right. But how do we actually implement that in the game, i.e., how do we translate that high-level description of the intended behavior into an action on our game objects?

Moving the player to the right translates to increasing (adding to) its x-coordinate. PyGame specifies the upper left corner as 0,0. Thus increasing the “x” coordinate moves to the right and increasing the “y” coordinate for any element, i.e., a positive shift, moves that element “down”.

A natural first approach to moving the the player might be something like:

if (event.key == pygame.K_RIGHT):
    # Handle right
    player.rect.x += DELTA_X

The above will work, but does not represent good OOP practices. What is the issue and what might be a better approach?

By “reaching” into the Player class, and indeed all into the base Box class we are “breaking” the encapsulation. Our game loop is now tightly coupled to the implementation of Player and vice-versa. Changes to one, e.g., if we replaced the rect instance variable with some other data structure, will require changes to other. Instead we want to encapsulate those implementation details and provide a “higher level” interface for moving the player. We can do so by defining shift_x and shift_y methods like shown below. By defining an “abstract” interface and using it within our game loop, any object that provides the shift_x, shift_y and render methods could be used as the player.

def shift_x(self, shift):
    self.rect.x += shift

def shift_y(self, shift):
    self.rect.y += shift

Handling the game end

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?

Recall our dual goals of encapsulation and reuse. We want to encapsulate implementation detail, like the use of Rect, so we would want to wrap colliderect in a collide (or other method). Since the rect instance variable/attribute is part of Box, we should implement the method in that class. That way the action is co-located with the data, all derived classes will inherit the method (i.e., can reuse) and derived classes are not tightly coupled to the implementation of Box. An example implementation is shown below. The collide 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.

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

Adapted from Philip Caplan.