Skip to content

Latest commit

 

History

History
180 lines (127 loc) · 9.29 KB

README.md

File metadata and controls

180 lines (127 loc) · 9.29 KB

Quick & Dirty: Using Pygame's DirtySprite & LayeredDirty (A tutorial)

Knowing how to use Pygame's various sprite classes is essential to getting good and efficient results when working on Pygame games.

Every computer game is made out of a loop that's constantly re-validating the input and the game data and refreshing the display accordingly. In fact, whenever I read the source code for a game I'm surprised by how many tasks get done on each iteration, that is, dozens of times per second.

You can imagine that drawing things on the screen is one of the heavier tasks, and that's where the DirtySprite and LayeredDirty classes come in. By using these two instead of the regular Sprite and Group classes, you can keep track on what parts of the screen need a refresh and what don't, and in turn, your game renders in a smarter and more efficient way.

In this short tutorial, I will show you a very basic use case for DirtySprite and how I implemented it in an existing game source.

The full source code for the tutorial, as well as a diff for each step, are all available to browse in the dirty_chimp project on GitHub.

Pummel The Chimp

As a simple basic game, we are going to use chimp.py, an extremely basic game based on the infamous "Win $$$" banner ads of the 1990s.

Pummel The Chimp, And Win $$$

The game comprises of an image of a chimp that's quickly floating from right to left, and a fist image representing the user's mouse cursor trying to punch it.

Writing this game is described in Pete Shinners' Line By Line Chimp tutorial, in case you need to wrap your head around basic Pygame principles. Its original source code is available under examples/chimp.py in the Pygame distribution.

There are two sprites used in this game: The chimp and the fist.
The chimp is always undergoing a change, either in its position (running around) or its angle (spinning when punched).
The fist follows the cursor movement, if there was any, and on a click event, it is lowered by a few pixels for a second, to simulate a hit.

The dirt

Using DirtySprite

First things first, we're going to use the DirtySprite class for both sprites. Of course, DirtySprite is a subclass of Sprite, so this change would have no effect for now:

class Fist(pygame.sprite.DirtySprite):
    def __init__(self):
        # call DirtySprite initializer
        pygame.sprite.DirtySprite.__init__(self)
class Chimp(pygame.sprite.DirtySprite):
    def __init__(self):
        # call DirtySprite initializer
        pygame.sprite.DirtySprite.__init__(self)

Voila, Fist and Chimp are now DirtySprite

Now, if we're going to keep the source as it is, the sprites would disappear immediately after first appearing. That is because we're going to make the rendering be dependent on their dirty flag (that is, they will only be updated if they have dirty = 1).

So to prevent this, for now, let's keep both of them always dirty after an update.
Add this to both classes:

    def update(self):
        ...
        self.dirty = 1

Note that as we mentioned before, the chimp is always either moving or spinning. That means we can have it always being dirty. So we're basically done with it!

Grouping the sprites

Now, in order to use the special dirty-rendering logic, we will need to use LayeredDirty, which is a group class for holding and handling multiple dirty sprites.

We're going to use it instead of the regular RenderPlain (right before the main loop):

    allsprites = pygame.sprite.LayeredDirty((fist, chimp))

Now comes the dirty drawing logic:
Instead of drawing everything to the screen and using flip to replace the screen content every time, we will use LayeredDirty's magic at the end of each loop iteration:

        # Draw Everything
        screen.blit(background, (0, 0))
        rects = allsprites.draw(screen)
        pygame.display.update(rects)

So we store the group's draw results in rects, which is basically only the areas of the screen that needs re-rendering. When passing these to update, we get exactly what we wanted.

And, because we keep adding to the screen and not necessarily re-drawing it, we're going to need to tell the LayeredDirty instance how to clear previous data, that is, it's going to use our predefined background when it clears parts of the screen. Let's add this line right before the main loop, after assigning to allsprites:

    allsprites.clear(screen, background)

We should be able to run the game now. But we want to be smarter about drawing the fist sprite.

Re-thinking Fist's rendering

Now, this is all nice but we want to be smarter about dirtying up our sprites.

Let's do that with Fist: Of course we'll still need to re-draw it whenever the user moved it (with the mouse cursor) or punched (using a click). But in any other case, i.e., when the mouse does not move, there's no need to change the Fist object.
So, let's change update, which currently handles every iteration the same (and sets dirty to 1).

We're going to move everything that's related to following the user's mouse to a new method, move:

    def move(self):
        "move the fist based on the mouse position"
        pos = pygame.mouse.get_pos()
        self.rect.midtop = pos
        self.dirty = 1

And update would just have to handle the punch animation, when necessary:

    def update(self):
        "handle the punching fist position"
        if self.punching:
            self.rect.move_ip(5, 10)
            self.dirty = 1

As you see, we haven't changed update's code at all, apart from splitting it into 2 functions and adding self.dirty = 1 when something changed. This would tell LayeredDirty to return the sprite's rect as something that requires rendering.

So now, whenever the user clicks the mouse, self.punching is going to be 1 (as defined in the punch method) and later on, update will change the sprite accordingly and mark it dirty.

We just need to add a hook to the mouse motion event, so that we'll call move whenever the mouse is moved. Let's add these two lines at the bottom of the event handling block:

            elif event.type == MOUSEMOTION:
                fist.move()

Some final tweaks

We're done with most of the work. There are just a few small issues to address at this point:

First, notice that even though we're using the smarter dirty rendering now, we're still re-rendering the background image on every loop iteration:

        # Draw Everything
        screen.blit(background, (0, 0))

This is useless, because we already set our LayeredDirty to clear with background.
So you can go ahead and remove that line altogether, we don't need it anymore.

Now, we have another small bug after a punch is made (i.e. a click event): The Fist sprite is lowered down in the screen, but never goes up. In the older code, it'd fix its position in the next iteration by following the user's cursor again. But in our code, if there was no mouse motion event, we don't bother updating the sprite.

We can fix that by calling move after a punch is finished, that is, at the end of unpunch:

    def unpunch(self):
        "called to pull the fist back"
        self.punching = 0
        self.move()  # reset to mouse's position

And finally, if you run the code we have so far you might notice that the chimp image can get on top of the fist sprite! That looks strange and unwanted.
Fortunately, we can fix that by ordering the sprites we pass LayeredDirty, so that the chimp sprite is always rendered first:

    allsprites = pygame.sprite.LayeredDirty((chimp, fist))

Now it all looks good.

And we're done.

This wasn't so difficult, was it?
Running the code now would render exactly the same game as the good old chimp.py.

Yup. Exactly the same game.
But - we were able to learn along the way, and get to know DirtySprite and LayeredDirty which are two classes that could provide a great deal of help in our future works.

Also, comparing the new game with the original version, I managed to see a slight improvement in performance: CPU percents were cut down from 5.5% to about 5.0%, and the process' threads number was fixed on 4 instead of around 5-7.
These are indeed extremely small changes, but keep in mind that (a) our example game is very simple as it is, and (b) it's not the most classic example, as we have the chimp sprite that's always dirty and re-rendering.
Still, I think that's pretty cool.

You can find the full code of this tutorial under the dirty_chimp project on GitHub.
The code for each step was posted at a separate commit, so you can check out the process in the project's Commit History page.