ICS 32 Winter 2022
Notes and Examples: The Spots Game
Background
In the previous code example, we explored how to use PyGame to draw animations one frame at a time. Each frame of our animation was drawn during one iteration of a game loop; each time through the game loop, we made whichever adjustments were necessary to make the next frame look different. This was a very good start, but as we start to work on problems that are more complex, it becomes more important to think more about how we're choosing to solve them.
One problem that we ignored in the previous example, but that we should consider how to solve, is how to handle resizing a PyGame window, so that the game can be played at a larger or smaller size. For example, we might like everything to be drawn proportional to the size of the window, so that doubling the window's width and height would cause each frame to be drawn at double the width and height, as well. Scaling our image proportionally requires thinking about coordinates differently.
Additionally, we were happy in the previous example to cram everything into a single game loop, written within a single function, but it's clear that this is not an approach that will scale well, from a design perspective. Eventually, we'll have so much input-handling, drawing, and world-altering code mixed together in each loop iteration, it will become difficult for us to understand it or to make changes to it. For this reason, we need to take that complexity and break it up. Broadly, we can divide a game like this into two components:
Every problem we face in writing games like these can be characterized as either a model problem or a view problem; if it seems like both, that's probably because it's two problems, one a model problem and one a view problem. We'll want to be fastidious about keeping these separate from each other, so best to use Python's mechanism for doing so: We'll write them in separate modules.
The Spots game
In lecture, we built a game that I called the Spots game. When launched, it displays an empty window (i.e., a background color with nothing else drawn). Clicking the mouse in that window somewhere causes a spot (an ellipse) to be drawn, and it also begins moving in a randomly-chosen direction. Clicking within an existing spot causes that spot to disppear, rather than creating a new one; if two spots overlap and the click occurs within both, the topmost spot (the one added most recently and, thus, drawn above the others) is removed.
Designing the model
In designing our model, our focus should be on trying to build tools that can assist us, rather than doing boring, repetitive work on our own. For this reason, we settled on the idea of writing a Spot class; an object of this class represents one spot. This allows us to have spots that can be empowered to answer useful questions and do things, rather than just be dumb holders of data. For example, our Spot class could have operations like these:
Finally, we could encapsulate the notion of the overall state of the Spots game in another kind of object, called a SpotsState. The state of the Spots game is represented by a list of spots, which grows and shrinks as spots are added and removed. Rather than just have SpotsState be a dumb container that stores a list of Spot objects, we'll empower it further, by including a method that handles the adding and removing of spots that occurs when the mouse is clicked.
Designing the view
Given our model classes, Spot and SpotsState, our view can be focused entirely on what our game looks like and how the user interacts with it. It draws the spots (appropriately sized), as well as handling user input like resizing the window or clicking the mouse inside of it. As long as our view contains a SpotsState object, it will be able to ask that object to do most of the heavy lifting whenever the user takes action.
Coordinates, revisited
We've seen before that PyGame uses a Cartesian-like coordinate system, in which each pixel (i.e., each dot that you can draw) is represented by a unique (x, y) coordinate. In a PyGame window whose surface is 500 pixels wide by 400 pixels tall, the coordinates look like this:
(0, 0) | (499, 0) | |
(0, 399) | (499, 399) |
However, if we doubled the width and height of the surface (to 1,000 pixels wide and 800 pixels tall), we would find that the coordinate system would change to this instead:
(0, 0) | (999, 0) | |
(0, 799) | (999, 799) |
Note, however, that if we draw the next frame of animation the same way we drew the previous one, the lines and shapes we drew previously will retain their original coordinates, so their size and position relative to the top-left will not change. If we want them to correspondingly double their size, we'll need to redraw them with new coordinates instead.
Alternative coordinate systems
While PyGame doesn't offer much relief — its coordinate system is what it is, based on counting pixels — we can do a little bit of extra work and fix the problem ourselves. Just because PyGame expects us to draw our shapes using pixel coordinates (i.e., all distances are measured in terms of pixels) doesn't mean that we have to conceptualize our shapes that way throughout our program. We just need the pixel coordinates at the moment we plan to actually draw the shapes; prior to that, we can use any coordinate system we'd like, then do a conversion just before we draw.
In lecture, we explored an alternative I called fractional coordinates, which used floating-point numbers to specify coordinates as a fraction of the distance from the left to the right (horizontally, in the x direction) and from the top to the bottom (vertically, in the y direction). These coordinates were independent of the size of the surface, allowing us to think of, say, a rectangle that fills up a particular proportion of the surface instead of a particular number of pixels.
The fractional coordinate system, as we defined it, looked like this instead:
(0.0, 0.0) | (1.0, 0.0) | |
(0.0, 1.0) | (1.0, 1.0) |
Applying an alternative coordinate system
To make use of an alternative coordinate system, though, we'd need to reconcile it with the coordinate system required by PyGame when it comes time to draw shapes. Here's what can be done:
Note that our model would want to rely on fractional coordinates throughout, because pixels are essentially meaningless except when it comes time to draw a frame of our animation. Fractional coordinates are the ones that have immutable meaning — (0.5, 0.5) is always the center in fractional coordinates, whereas the pixel coordinate of the center changes every time the window's size changes.
Handling additional kinds of events using PyGame
PyGame's input arrives in our program in the form of events, which are generated behind the scenes whenever the user does things like press keys on the keyboard, click their mouse button, and so on. Those events are given to us when we call the pygame.event.get() function, which returns all of the events that have occurred since the last time it was called. By calling it once per iteration of our game loop, we get the effect of events appearing to be handled more or less as they happen.
This example handles two new kinds of events that we've not yet seen. Events in PyGame carry attributes that describe the event in more detail. They all have a type attribute that specifies what kind of event they are; depending on what kind of event we have, though, we can expect to find other attributes available, as well.
There are several more kinds of events that PyGame can handle, as well, which you will find described in the documentation for the pygame.event library here.
The code
A completed, lightly-commented version of the Spots game from lecture is avialable below.