Python:Pygame OOP Framework

From GPWiki
Jump to: navigation, search
40x-binary.png This tutorial was written for an older version of Python and some features may be depreciated.
For more information visit What's New In Python 3.0.

Introduction

When you find tutorials on game programming, you tend to find one of two things: The very basics, or specialized topics. Somewhere in the middle is the assumption that you've built on the basics and now just need specific help. Very few places actually have tutorials for this middle ground.

This tutorial intends to do just that, using the pygame library for python. While it's language specific in that, the ideas are portable to any language.

Requirements

The code in this tutorial has been tested on two of my machines - a linux machine and an iBook - so I'm reasonably sure of its portability. You'll need the following:

  • python version 2.3 or later
  • pygame version 1.6 with TTF support built in


A Word About Style

The framework will use a number of python files, but they'll be large and we don't want to show them all at once. So we'll just be showing parts as we go along, and then the entire code dump at the end. Portions of code will look like this:

example.py

if __name__ == '__main__':
  print "Hello, world!"

And later, when we add code to that:

example.py

def hiWorld():
  print "Hello, world!"

Sometimes, a method or class will be too freaking huge for me to put in one place. Python's pretty sensitive to indentation, so I'll try not to do this, but at times it makes for far more clear code. In theory, you should be able to just stitch the code together.

example.py (reallyLongFunction, part 1)

def reallyLongFunction():
  print "This is a really really",

example.py (reallyLongFunction, part 2)

  print "really really REALLY long function"

Finally, the code dump:

example.py (entire)

def reallyLongFunction():
  print "This is a really really",
  print "really really REALLY long function"
 
def hiWorld():
  print "Hello, world!"
 
if __name__=='__main__':
  hiWorld()

The Very Basics

Because I've done too much Smalltalk programming, I tend to over-object-orient things. One of the nice things Smalltalk had was a built-in 'SubclassShouldImplement' function, which told you that you'd called an abstract method. I re-create that for python here:

SubclassShouldImplement

common.py

class SubclassShouldImplement(Exception):
    def __init__(self, msg="A method was called which should have been overridden"):
        Exception.__init__(self,msg)

This allows us to have classes which are explicitly abstract. There's also the nice side-effect that pychecker assumes any class method which does nothing but raise an exception is abstract, and so it'll warn us if we accidentally call it.

Updateable

Every game has objects that need to be updated on a regular basis. This very simple base class is for them:

gui.py

from pygame.constants import *
from common import SubclassShouldImplement
 
class Updateable:
 
    def update(self,delay):
        "delay is the time in seconds passed since last iteration"
        raise SubclassShouldImplement

Paintable

Now for things which go on the screen:

gui.py

class Paintable:
    def __init__(self, loc=None):
        """loc is a tuple of the upper-left location to paint this paintable at.
        Subclasses (such as Mouseable) depend on the first two entries being x,y"""
 
        self.loc = loc
 
    def paint(self,screen):
        raise SubclassShouldImplement

This is to be the parent class of everything we put on the screen. All it needs to do is know where it is and how to paint itself.

Mouseable

Of course, being able to paint things is handy, but being able to actually move them and interact would be nicer. Thus, we'll make another abstract class. This will inherit from Paintable, as in order to click on something it has to be able to show up on the screen to begin with.

gui.py

class Mouseable(Paintable):    
    def __init__(self,bounds = None):
        """bounds is the location and width/height of the mouseable.  If None,
        we're everywhere!"""
 
        Paintable.__init__(self,bounds)
        self.buttonState = MOUSEBUTTONUP
 
    def mouseEvent(self,event):
        "event is a MOUSE* event, this routine decodes it and calls one of the subs"
        x,y = event.pos
        if event.type == MOUSEBUTTONDOWN:
            self.buttonState = event.type
            self.mouseDownEvent(x,y)
        elif event.type == MOUSEBUTTONUP:
            self.buttonState = event.type
            self.mouseUpEvent(x,y)
        elif event.type == MOUSEMOTION:
            if self.buttonState == MOUSEBUTTONDOWN:
                self.mouseDragEvent(x,y)
            self.mouseMoveEvent(x,y)
 
    def mouseDownEvent(self,x,y):
        pass
 
    def mouseUpEvent(self,x,y):
        pass
 
    def mouseDragEvent(self,x,y):
        pass
 
    def mouseMoveEvent(self,x,y): 
        pass

The most important thing that this class does is to take raw pygame events and translate them into function calls. Subclasses only need to override any of the mouse*Event functions.

Note that this doesn't actually make sure the mouse event took place within the bounds of the Mouseable object. Our engine will do this before sending the event, but it would possibly be more object-oriented for us to do so here.

Keyable

Finally, we need a class for those objects which will listen to us hitting buttons

gui.py

class Keyable:
 
    def __init__(self, keys=None):
        """keys is a list of keys that this will respond to.  If None, it listens
           to everything"""
        self.keys = keys
 
    def maskEvent(self, key, unicode, pressed):
        if self.keys:
                if key not in self.keys:
                    return
        self.keyEvent(key,unicode,pressed)
 
    def keyEvent(self,key,unicode, pressed):
        raise SubclassShouldImplement

Here, a Keyable registers a list of keys that it will respond to. Our system calls maskEvent, which then calls keyEvent.

The State Machine

Typically, a game has a number of different modes. A game of pong, for instance, has a title screen, the actual playing of the game, and a game over screen. What you'd normally do is have a variable indicating what state you're on, and branch on that. Of course, the whole reason I'm writing the tutorial is to avoid that sort of thing.

State

The current state of the game can be represented by an object. Each state, after all, has a certain number of things in common:

states.py

import pygame
import sys
import gui # So we can have a common interface between gui stuff and state stuff
from  common import SubclassShouldImplement
from pygame.locals import *
 
class State(gui.Keyable,gui.Mouseable):
    def __init__(self, driver,screen):
        gui.Keyable.__init__(self) # States listen to everything
        gui.Mouseable.__init__(self)
        self._driver = driver
        self.screen = screen
 
    def activate(self):
        pass
 
    # maskEvent is handled by Keyable
 
    def keyEvent(self,key,unicode,pressed):
        pass
 
    def paint(self,screen):
        raise SubclassShouldImplement
 
    def reactivate(self):
        pass
 
    def update(self, delay):
        pass

These functions are the basis of everything you'd want to do in a state. keyEvent and paint you're already familiar with. activate is called when the state is first made active, reactivate when another state's

This is, of course, just the bare bones of what a state should be. That's the whole idea behind making it object oriented, after all.

StateDriver

Most of the work in the game will be done by the states, but on their own they don't really do much. There's need for glue to put them together. Here's the basic outline of the StateDriver class:

states.py (StateDriver, part 1)

class StateDriver:
    def __init__(self, screen):
        self._states = []
        self._screen = screen
 
    def done(self):
        self._states.pop()
        self.getCurrentState().reactivate()
 
    def getCurrentState(self):
        try:
            return self._states[-1]
        except IndexError:
            self.quit()
 
    def getScreenSize(self):
        return self._screen.get_size()
 
    def quit(self):
        pygame.quit(); sys.exit()
 
    def replace(self, state):
        self._states.pop()
        self.start(state)

The StateDriver acts like a stack. While this isn't important most of the time, it's most useful for when you need to implement something like a 'paused' state. Simply push the pause state on the stack when the game needs to stop, and pop it when you're done.

States respond to events, as you've seen above, but who sends the events out? This also seems like the preview of the StateDriver:

states.py (StateDriver, part 2)

    def run(self):
        currentState = self.getCurrentState()
        lastRan = pygame.time.get_ticks()
        while(currentState):
            # poll queue
            event = pygame.event.poll()
            while event.type != NOEVENT:
                if event.type == QUIT:
                    currentState = None
                    break
                elif event.type == KEYUP or event.type == KEYDOWN:
                    if event.key == K_ESCAPE:
                        currentState = None
                        break
                    if event.type == KEYUP:
                        currentState.maskEvent(event.key, None, 0)
                    if event.type == KEYDOWN:
                        currentState.maskEvent(event.key, event.unicode, 1)
                elif event.type == MOUSEMOTION:
                    currentState.mouseEvent(event)
                elif event.type == MOUSEBUTTONDOWN or \
                     event.type == MOUSEBUTTONUP:
                        currentState.mouseEvent(event)
 
                event = pygame.event.poll()
            self._screen.fill( (0, 0, 0) )
            if currentState:
                currentState.paint(self._screen)
 
                curTime = pygame.time.get_ticks()
                elapsed = float(curTime-lastRan)/1000.0
                currentState.update(elapsed)
                lastRan = curTime
 
                currentState = self.getCurrentState()
 
                pygame.display.flip()
                pygame.time.delay(40);
 
    def start(self, state):
        self._states.append(state)
        self.getCurrentState().activate()

There are a few design decisions here. For instance, if the player hits the escape key, the program ends right then and there. Secondly, the driver waits for 40 milliseconds each frame. This is primarily so the rest of the system doesn't get bogged down. Unfortunately, this locks the game down to a fixed FPS at best. For now, it'll do, but in the future you might want to replace it. Thirdly, it clears the screen each time. We could possibly save time by not doing that, and keeping track of what's changed and just re-painting that. Clearing the whole screen is simpler, though.

GuiState

While we've got a basic state, we don't have one that will actually do anything. We went through all the trouble of making states and paintables and everything, we should do something with them!

states.py

class GuiState(State):
    def __init__(self,driver, screen):
        State.__init__(self,driver,screen)
        self.paintables = []
        self.mouseables = []
        self.keyables = []
        self.updateables = []
 
    def add(self,item):
        # Add to the appropriate list(s) based on type
        if isinstance(item,gui.Paintable):
            self.paintables.append(item)
        if isinstance(item,gui.Mouseable):
            self.mouseables.append(item)
        if isinstance(item,gui.Keyable):
            self.keyables.append(item)
        if isinstance(item,gui.Updateable):
            self.updateables.append(item)
 
    def paint(self,screen):
        for paintable in self.paintables:
            paintable.paint(screen)
 
    def keyEvent(self,key,unicode,pressed):
        for keyable in self.keyables:
            keyable.keyEvent(key,unicode,pressed)
 
    def mouseEvent(self,event):
        x,y = event.pos
        for mouseable in self.mouseables:
            x1,y1 = mouseable.loc[0:2]
            try:
                w,h = mouseable.loc[2:4]
            except IndexError:
                w,h = self.screen.get_width(),self.screen.get_height()
            if x >= x1   and y >= y1 and \
               x <  x1+w and y <  y1+h:
                mouseable.mouseEvent(event)
 
    def update(self,delay):
        for updateable in self.updateables:
            updateable.update(delay)

Moving On

You've got a framework, but what can you do with it? While it's (hopefully) easy enough to take the code here and create something with it, tutorials are designed to teach - thus I've made a follow-up on using the framework to make Pong

Code Dump, Part I

common.py

class SubclassShouldImplement(Exception):
    def __init__(self, msg="A method was called which should have been overridden"):
        Exception.__init__(self,msg)

gui.py

from common import SubclassShouldImplement
from pygame.constants import *
 
class Paintable:
    """loc is a tuple of the upper-left location to paint this paintable at.
    Subclasses (such as Mouseable) depend on the first two entries being x,y"""
    def __init__(self, loc=None):
        self.loc = loc
 
    def paint(self,screen):
        raise SubclassShouldImplement
 
class Mouseable(Paintable):
    """bounds is the location and width/height of the mouseable.  If None,
      we're everywhere!"""
 
    def __init__(self,bounds = None):
        Paintable.__init__(self,bounds)
        self.buttonState = MOUSEBUTTONUP
 
    def mouseEvent(self,event):
        "event is a MOUSE* event, this routine decodes it and calls one of the subs"
        x,y = event.pos
        if event.type == MOUSEBUTTONDOWN:
            self.buttonState = event.type
            self.mouseDownEvent(x,y)
        elif event.type == MOUSEBUTTONUP:
            self.buttonState = event.type
            self.mouseUpEvent(x,y)
        elif event.type == MOUSEMOTION:
            if self.buttonState == MOUSEBUTTONDOWN:
                self.mouseDragEvent(x,y)
            self.mouseMoveEvent(x,y)
 
    def mouseDownEvent(self,x,y):
        pass
 
    def mouseUpEvent(self,x,y):
        pass
 
    def mouseDragEvent(self,x,y):
        pass
 
    def mouseMoveEvent(self,x,y): 
        pass
 
class Keyable:
    def __init__(self, keys=None):
        """keys is a list of keys that this will respond to.  If None, it listens
     to everything"""
        self.keys = keys
 
    def maskEvent(self, key, unicode, pressed):
        if self.keys:
                if key not in self.keys:
                    return
        self.keyEvent(key,unicode,pressed)
 
    def keyEvent(self,key,unicode, pressed):
        raise SubclassShouldImplement
 
class Updateable:
 
    def update(self,delay):
        "delay is the time in seconds passed since last iteration"
        raise SubclassShouldImplement

states.py

import pygame
import sys
import gui # So we can have a common interface between gui stuff and state stuff
from  common import SubclassShouldImplement
from pygame.locals import *
 
class StateDriver:
    def __init__(self,screen):
        self._states = []
        self._screen = screen
 
    def done(self):
        self._states.pop()
        self.getCurrentState().reactivate()
 
    def getCurrentState(self):
        try:
            return self._states[-1]
        except IndexError:
            raise SystemExit  # we're done if theren't any states left
 
    def getScreenSize(self):
        return self._screen.get_size()
 
    def quit(self):
        # Was 'raise SystemExit', but pychecker assumes any function that
        # unconditionally raises an exception is abstract
        sys.exit(0) 
 
    def replace(self, state):
        self._states.pop()
        self.start(state)
 
    def run(self):
        currentState = self.getCurrentState()
        lastRan = pygame.time.get_ticks()
        while currentState:
            # poll queue
            event = pygame.event.poll()
            while event.type != NOEVENT:
                if event.type == QUIT:
                    currentState = None
                    break
                elif event.type == KEYUP or event.type == KEYDOWN:
                    if event.key == K_ESCAPE:
                        #currentState = None
                        #break
                        pass
                    if event.type == KEYUP:
                        currentState.maskEvent(event.key, None, 0)
                    if event.type == KEYDOWN:
                        currentState.maskEvent(event.key, event.unicode, 1)
                elif event.type == MOUSEMOTION:
                    currentState.mouseEvent(event)
                elif event.type == MOUSEBUTTONDOWN or \
                     event.type == MOUSEBUTTONUP:
                        currentState.mouseEvent(event)
 
                event = pygame.event.poll()
            self._screen.fill( (0, 0, 0) )
            if currentState:
                currentState.paint(self._screen)
 
                curTime = pygame.time.get_ticks()
                elapsed = float(curTime-lastRan)/1000.0
                currentState.update(elapsed)
                lastRan = curTime
 
                currentState = self.getCurrentState()
 
                pygame.display.flip()
                pygame.time.delay(40);
 
    def start(self, state):
        self._states.append(state)
        self.getCurrentState().activate()
 
class State(gui.Keyable,gui.Mouseable):
    def __init__(self, driver,screen):
        gui.Keyable.__init__(self) # States listen to everything
        gui.Mouseable.__init__(self)
        self._driver = driver
        self.screen = screen
 
    def activate(self):
        pass
 
    # maskEvent is handled by Keyable
 
    def keyEvent(self,key,unicode,pressed):
        pass
 
    def paint(self,screen):
        raise SubclassShouldImplement
 
    def reactivate(self):
        pass
 
    def update(self, delay):
        pass
 
class GuiState(State):
    def __init__(self,driver, screen):
        State.__init__(self,driver,screen)
        self.paintables = []
        self.mouseables = []
        self.keyables = []
        self.updateables = []
 
    def add(self,item):
        # Add to the appropriate list(s) based on type
        if isinstance(item,gui.Paintable):
            self.paintables.append(item)
        if isinstance(item,gui.Mouseable):
            self.mouseables.append(item)
        if isinstance(item,gui.Keyable):
            self.keyables.append(item)
        if isinstance(item,gui.Updateable):
            self.updateables.append(item)
 
    def paint(self,screen):
        for paintable in self.paintables:
            paintable.paint(screen)
 
    def keyEvent(self,key,unicode,pressed):
        for keyable in self.keyables:
            keyable.keyEvent(key,unicode,pressed)
 
    def mouseEvent(self,event):
        x,y = event.pos
        for mouseable in self.mouseables:
            x1,y1 = mouseable.loc[0:2]
            try:
                w,h = mouseable.loc[2:4]
            except IndexError:
                w,h = self.screen.get_width(),self.screen.get_height()
            if x >= x1   and y >= y1 and \
               x <  x1+w and y <  y1+h:
                mouseable.mouseEvent(event)
 
    def update(self,delay):
        for updateable in self.updateables:
            updateable.update(delay)

Other OOP Tutorials

Pygame OOP tutorial