Recall that we discussed two potential benefits for OOP:
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).
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 instance variables) 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.
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:
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.
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 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:
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).
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
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.
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
instance variable 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
instance variable 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.