ICS 32A Fall 2023
Notes and Examples: Inputs and Sounds


PyGame inputs and outputs

In the Spots example, we wrote a PyGame-based game that had one kind of input and one kind of output. It used input events from the mouse (notably, clicks of the primary mouse button) and drew graphics in a window for its output. But those aren't the only kinds of inputs and outputs that PyGame can handle.

On the input side of the ledger, PyGame can detect not only the mouse button, but also fine-grained mouse movements, joystick/gamepad interactions, and can also provide a fairly detailed status of every key on the keyboard.

From an output perspective, PyGame is not limited only to drawing graphics, as we've seen. It can also load and manipulate images from files, then display them. It can play sounds and music. It can also alter the mouse cursor so that it appears different from the usual defaults.

Which of these features you need is a function of the game you're building, of course, but it's handy to have all of these things at your disposal when you need them. Like most libraries, the cost for using these features is understanding how you ask the library to handle them for you. This example takes you through a few of these that might be of use to you in Project 5.


A little background inspiration

As a young kid, I played a lot of games on one of the first popular home video game consoles, the venerable Atari 2600. One of my favorite games in those days was simply called Adventure, in which your player could roam around a small world consisting of locked castles (and a key to open each one), mazes, and a few other tools (a sword, a magnet, and a bridge). The world was populated also by dragons (whose main goal was to eat you, but which you could kill with the sword) and a bat, which would steal items from you and fly away with them. The game's goal was simple: Find a glowing chalice and return it to the gold castle, where you began the game. To get an idea of what the game looked and sounded like, take a look at this example of someone playing through the simplest of its three levels.

Of course, this game comes from the dawn of the video game revolution, first released in 1980 — the same year I started kindergarten. Games have come far enough that Adventure seems trite and silly nowadays, but I found myself completely fascinated by it when I played it as a kid; I simply hadn't seen anything quite like it before, so it captivated me.

And those simple games still have a lot to teach us about fun, about playability, and also about developing quality software. In this example, we'll use Adventure as our inspiration and use it to explore alternative forms of input and output in PyGame.


Keyboard inputs

There are two different ways to read keyboard input in PyGame, mainly because there are two different ways that you might be interested in finding out about it.

As it turns out, the latter of these is something you could do yourself if all you had was the former, but it would require that you keep track of the current state of each key — noting whenever it went down and then came back up again. But because it's so convenient to be able to ask "Which of the arrow keys is down right now?", PyGame provides both mechanisms separately.

How you know which of these mechanisms is right for you depends on what you'll want to know.

The keys themselves are described by constants that are defined in the pygame module. Each key on the keyboard has such a constant. Examples include pygame.K_a for the 'A' key, pygame.K_MINUS for the '-' key, pygame.RIGHT for the right arrow key, and pygame.RETURN for the Enter/Return key (different keyboards will call this "Enter" or "Return", but PyGame combines them together). You can find a full list of these constants in the pygame.key documentation.

Events when the state of one key changes

When the state of a key changes, you can find out by checking the sequence of events you get back from pygame.event.get(). For every key that has been pressed since the last time you called pygame.event.get(), you'll receive a pygame.KEYDOWN event whose key attribute specifies what key it was. For every key that has been released, you'll receive a pygame.KEYUP event, which will also have a key attribute similarly. In each case, the key attribute will be one of the constants described earlier. For example, if we want to know if someone has pressed the left arrow key, we could check this way.


for event in pygame.event.get():
    ...
    elif event.type == pygame.KEYDOWN:
        if event.key == pygame.K_LEFT:
            ...

Getting the state of all keys simultaneously

If, instead, we want to check the state of all keys, this is something we can do separately from our usual call to pygame.event.get(). Rather than being told when things change, we can simply interrogate the current state of all keys on every frame, then react accordingly. The pygame.key.get_pressed() function returns an object that is effectively a dictionary that maps keys to boolean values that represent the state of each key — True being pressed down, False being released. We can then separately look at the state of each key we're interested in.


keys = pygame.key.get_pressed()

if keys[pygame.K_LEFT]:
    ...

if keys[pygame.K_RIGHT]:
    ...

if keys[pygame.K_UP]:
    ...

if keys[pygame.K_DOWN]:
    ...

Note that we'll tend to use a sequence of if statements here, rather than a single if/elifelif, because we usually use this mechanism because we might want to take action on multiple different keys being held down in the same frame. We can only do that if we check each key separately (i.e., just because the left arrow key is pressed doesn't mean we aren't interested in whether the up arrow key is also pressed).


Loading and manipulating images

We've previously seen that you can use the pygame.draw module to draw various shapes on PyGame's surfaces. If what you want is to draw rectangles, ellipses, lines, and so on, then pygame.draw is perfect for your needs. However, games are a lot more visually appealing when they can display finer-grained images, rather than just simply, clunky shapes. Fortunately, PyGame can load and manipulate images in a variety of well-known formats, such as JPG, PNG, GIF, and BMP, then draw them on a surface.

Loading an image

The pygame.image module contains tools for loading and manipulating images in various well-known formats. The pygame.image.load() function allows you to load an image from a file; you tell it where the file is, it will load the image and give you back a surface containing that image's pixels. (Note that a surface in PyGame is just a rectangular area containing pixels; what makes those pixels visible is that you draw them on PyGame's display surface.)


peke_image = pygame.image.load('gray_peke.png')

Of course, the file has to exist, and it has to actually be in a recognizable image format (one that PyGame knows how to process).

Blitting

When it comes time to make our image visible on another surface, such as PyGame's display surface, we need to blit it on to that surface. The term "blit" is a very old shorthand for what was once called a "block transfer" or a "bit block transfer," which is literally the copying of a large number of bits from one place to another. When we want an image to be visible on a surface, that's exactly what we want: Take the bits that describe that image and place them into the appropriate location on the surface.

So, generally, what we need to specify are two things:

  1. What image do we want to appear on the surface?
  2. Where on that surface do we want the image to appear?

The pygame.Surface class has a method called blit() that can do this. You pass it two arguments: the image and a two-element tuple specifying the (pixel) x- and y-coordinates of where you want the top-left corner of the image to appear on the surface. PyGame does the rest. So, for example, if we had a surface called some_surface and an image called peke_image, this would place the image on the surface, withe the top-left of the image appearing at the pixel coordinate (200, 250).


some_surface.blit(peke_image, (200, 250))

The image covers whatever used to be in the pixels. There are also ways to make it blend with what's already there, if you prefer. Lots more information is available in PyGame's documentation.

Scaling an image

One kind of image manipulation that PyGame supports is the ability to scale it, which proportionally alters its size, while generally preserving its visual characteristics. Of course, if you scale something small enough, it will begin to lose those characteristics; similarly, if you scale something large enough, it will start to look blurry, because there wasn't enough detail in the original to convey the larger picture you're trying to create. Nonetheless, PyGame can do a pretty decent job of scaling an image to a different size, while keeping it looking relatively decent.

For example, below are two images. One is a simple drawing of a Pekingese; the other is the same drawing, but scaled to be smaller (about one-quarter the width and one-quarter the height).

Pekingese, larger Pekingese, scaled to be smaller

If you look at the images carefully, you'll see that the small one has the characteristics of the larger one — the shadows around the ear and tail, the darker area below the nose, and so on — even though those details are now proportionally smaller.

PyGame is capable of performing this kind of scaling whenever you need it. Because this can be a somewhat time-intensive operation, it's not the kind of thing you'll necessarily want to do a lot of (e.g., you won't want to do this on a large number of images in every frame of your animation), but you also will find that you won't often need to do a lot of it. For example, if we use the Pekingese above as our "player" in our Adventure game, instead of a single-colored rectangle, we'll only need to scale the image when its size is different than it was in the previous frame, which only happens twice:

If, in between, we simply store the scaled image, we can blit it on to every one of our frames without re-scaling it, which can save a significant amount of processing time. This can have an effect on the battery life of the device running our game, as well as whether or not our game can even play at the speed we want (e.g., if we do enough scaling, we won't be able to finish all of it to keep up with our game's frame rate).


Playing sounds via PyGame's mixer

Of course, games are a lot more engaging when they play sounds, so PyGame provides a facility to do that. Playing sounds is done via PyGame's mixer. A mixer is what it sounds like: It mixes together multiple sound waves, by combining them into a single one. Operating systems generally include sound mixers, as well, which is why you can be watching a video in a web browser that generates one sound, while listening to music emanating from iTunes at the same time; what you hear is the combination of both sounds, mixed together by your operating system's mixer. PyGame includes a pygame.mixer module, which allows you to mix multiple sounds together in your game.

The pygame.mixer module provides a fair amount of functionality, so it's actually somewhat complex. It's organized around the concept of channels, with each of those channels being capable of playing a single sound at a time. Depending on the complexity of your sound-generating needs — how many sounds you might need to generate, or how often you might have multiple simultaneous sounds being played — you may need to manage how many channels there are, find open channels when you need to play a sound, or even be notified via events when channels become available.

We'll start by keeping things fairly simple, though; we'll just generate one sound at a time. For this, we need to be able to do two things.

Lots more documentation on the pygame.mixer module is available here, if you want to see what kinds of tools are available for managing all of this.


The code

Below are two versions of the code that we wrote in lecture. The first is the beginnings of an implementation of the Adventure game from the Atari 2600 — with a single-color, rectangular-shaped "player" that we can move around, which, when it exits the screen on one side, wraps around to the opposite side.

In the second version, we augmented this with a couple of additional assets: one image that we used so that our player wouldn't just be a single-colored rectangle, and one sound that we played whenever the user pressed the Enter (or Return) key.