Keyboard interactions

In the previous tutorial, we made a simulation of a gas cloud collapsing under its own gravity. One problem with the simulation is that, unless you are very lucky, the particles eventually move off the screen and you're left staring at a blank screen. In this tutorial, we will:

  • Create functions to scroll and zoom the display
  • Call these functions in response to keystrokes
  • Add the ability to pause our simulation
  • Link keystrokes to functions using a dictionary

These are highly useful for many simulations and other programs, such as games. Below is a video demonstrating scrolling and zooming during the simulation.

It's important to note that we don't want to actually change the position of any of the particles. This may seem odd, but instead of changing the position of the particles, we change how we display them. Scrolling and zooming are related to the display, and as before, we want to separate the display from the mechanics of the simulation. To illustrate why separating the display from the mechanics is important, imagine we zoom out of out simulation by dividing the x and y attributes of each particle by two. The particles would now be twice as close to one another as before, thus giving the appearance of zooming out. However, we would then have to take into account the difference in distances when checking for collisions and when calculating gravity. It's much simpler to keep the 'actual' position of the particles the same and just change how particles are displayed.

The UniverseScreen

Since we want to change how the universe is displayed, we need to create some variables to keep track of these changes. We could just create some global variables, but it's neater to create an object to tie all the variables and functions together. Create the following object:

class UniverseScreen:
    def __init__ (self, width, height):
        self.width = width
        self.height = height
        (self.dx, self.dy) = (0, 0)
        (self.mx, self.my) = (0, 0)
        self.magnification = 1.0
        
    def scroll(self, dx=0, dy=0):
        self.dx += dx * width / (self.magnification*10)
        self.dy += dy * height / (self.magnification*10)
        
    def zoom(self, zoom):
        self.magnification *= zoom
        self.mx = (1-self.magnification) * self.width/2
        self.my = (1-self.magnification) * self.height/2
        
    def reset(self):
        (self.dx, self.dy) = (0, 0)
        (self.mx, self.my) = (0, 0)
        self.magnification = 1.0

The UniverseScreen object contains the variables and functions required for scrolling, zooming, and resetting the display to its original state. Create a UniverseScreen instance with the same width and height as the universe instance and the screen instance:

universe_screen = UniverseScreen(width, height)

Scrolling

Scrolling involves adding a constant value to either the x or y position of the particles. For example, if we scroll left, then we display every object a few pixels to the right, which means adding a small amount to their x attribute. We store the amount that we change the x and y attributes as the UniverseScreen's dx and dy attributes. We can now alter the display code to:

We therefore update the code for displaying particles to:

x = int(p.x + universe_screen.dx)
y = int(p.y + universe_screen.dy)

if size < 2:
    pygame.draw.rect(screen, p.colour, (x, y, 2, 2))
else:
    pygame.draw.circle(screen, p.colour, (x, y), size, 0)

We can now scroll left, for example, by calling:

universe_screen.scroll(dx=1)

This will move all the particles 40 pixels right (the width of the screen divided by 10). Later we'll attach this function call to a keyboard input.

Zooming

The equation for zooming is more complicated because not only do we have to change the relative position of the particles (the more we zoom in, the further apart they should appear), but we also need to alter the x and y attributes such that a particle in the dead centre of the screen remains in the centre. We could do this by altering the dx and dy attribute of the UniverseScreen, but I found it easier to create new variables, mx and my. 

If you look at the zoom() function above, you can see the first thing it does is to change the magnification attribute, which determines how zoomed in or out we are. A magnification of two means that we are twice as "zoomed in" so the distance between particles should appear twices as big (and so particles should also appear to move twice as fast). In addition, we should double the size of the particles and half the effect of scrolling so that we always move a tenth of the screen. Below is the modified display code:

x = int(universe_screen.mx + (universe_screen.dx + p.x) * universe_screen.magnification)
y = int(universe_screen.my + (universe_screen.dy + p.y) * universe_screen.magnification)
size = int(p.size * universe_screen.magnification)
        
if size < 2:
    pygame.draw.rect(screen, p.colour, (x, y, 2, 2))
else:
    pygame.draw.circle(screen, p.colour, (x, y), size, 0)

Notice that as well as multiplying the particles' x and y attributes by the magnification parameter we also multiply the dx and dy parameters. This is because previous scrolling movements also need to be magnified. If you look back at the scroll() function, you can see that we divide the distance we scroll by the magnification value, so as we zoom in, we still scroll a tenth of a screen at a time.

The two parameters we haven't yet discussed are mx and my. These values are set by the zoom() function and are required so that as you zoom in and out, the screen remains centred.

Responding to keyboard events

We now have functions that change the attributes of universe_screen and a display that uses these attributes to correctly draw the particles. The next set is to call these functions in response to keystrokes. Responding to keystroke events is very similar to responding to mouse events, only instead of responding to MOUSEBUTTONDOWN or MOUSEBUTTONUP events, we respond to KEYDOWN events. For example:

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        if event.type == pygame.KEYDOWN:
            print event.key

One difference is that KEYDOWN events have an attribute, event.key. If you run the program now, you will see a number printed to your terminal whenever you press a key. Each key is associated with a different number, so we can determine which key has been pressed. Rather than work out which number is associated with each key (e.g. pressing 'a' gives us 97), Pygame defines constants with fairly easy-to-remember names that represent each of keys (e.g. pygame.K_a = 97). You can find a full list here. For example, we respond to the arrow keys like so:

if event.type == pygame.KEYDOWN:
    if event.key == pygame.K_LEFT:
        universe_screen.scroll(dx=1)
    elif event.key == pygame.K_RIGHT:
        universe_screen.scroll(dx=-1)
    elif event.key == pygame.K_UP:
        universe_screen.scroll(dy=1)
    elif event.key == pygame.K_DOWN:
        universe_screen.scroll(dy=-1)
    elif event.key == pygame.K_EQUALS:
        universe_screen.zoom(2)
    elif event.key == pygame.K_MINUS:
        universe_screen.zoom(0.5)
    elif event.key == pygame.K_r:
        universe_screen.reset()

We can now call scroll events when the arrow keys are pressed, zoom out when the minus key is pressed, zoom in when the equals key is pressed and call a reset() function when 'r' key is pressed. Note that because of the way the scroll() function is written, it can take two parameters (and can scroll diagonally), even though we don't use it this way. The reset() function is a method of UniverseScreen that resets its attributes:

def reset(self):
    (self.dx, self.dy) = (0, 0)
    (self.mx, self.my) = (0, 0)
    self.magnification = 1.0

Play/Pause

In order to pause the simulation we need to add a conditional to the code that updates the universe:

if not paused:
    universe.update()

Set paused to equal False somewhere outside the simulation loop, then add a new event.key test:

elif event.key == pygame.K_SPACE:
    paused = (True, False)[paused]

This toggles the 'paused' value, so if it's True, then it become False; if it's False then it becomes True. The reason this code works is that True is considered equal to 1, while False is considered equal to 0 (more details here), so we can use the 'paused' variable to select from a tuple containing True and False. If 'paused' is False, then the first value is selected; if 'paused' is True, then the second value is selected. Don't forget to set 'paused' to False (or True if you prefer) somewhere before the simulation loop starts.

Bonus section - more anonymous functions

The simulation is now complete. However, the code is a little inelegant and inefficient, what with having six consecutive elif statements. In some programming languages there is a case ... switch pattern for this sort of situation. Python doesn't have such statements, but we can get around this by using a dictionary of functions. If you've had enough of anonymous functions after the previous tutorial, then feel free to skip this section. Maybe it's just because I've been learning Lisp that I have a compulsion to include the lambda function anywhere I can.

To avoid the elif statements, we create a dictionary that links the keyboard input with the relevant function:

key_to_function = {
    pygame.K_LEFT:   (lambda x: x.scroll(dx = 1)),
    pygame.K_RIGHT:  (lambda x: x.scroll(dx = -1)),
    pygame.K_DOWN:   (lambda x: x.scroll(dy = -1)),
    pygame.K_UP:     (lambda x: x.scroll(dy = 1)),
    pygame.K_EQUALS: (lambda x: x.zoom(2)),
    pygame.K_MINUS:  (lambda x: x.zoom(0.5)),
    pygame.K_r:      (lambda x: x.reset())}

You should be able see what's going on here: the keystroke value is linked to a lambda function, which takes a parameter, x, and calls a function of x. We are going to call each function with the parameter universe_screen, so we will call universe_screen's functions. In fact, this is the main reason for creating a UniverseScreen class. We can now use this dictionary to immediately call the right function (once we've checked that the keystroke is in the dictionary):

if event.key in key_to_function:
    key_to_function[event.key](universe_screen)
elif event.key == pygame.K_SPACE:
    paused = (True, False)[paused]

Note that we can't create a lambda function that changes the value of 'paused'. Lambda functions cannot have 'side-effect', i.e. they can't change values defined outside of their scope. We can get around this by making 'paused' an attribute of the UniverseScreen class (because we pass universe_screen to the lambda function as x, so could access it as x.paused). I've left it this way to highlight a limitation of anonymous functions.

AttachmentSize
particle_tutorial_13.txt2.96 KB
PyParticles3.txt6.87 KB

Comments

I love your tutorials, very clear and clean code.

you make it look simply, but i still have a long way to go :D

thanks for your work and time in creating those tutorials!

Post new comment

The content of this field is kept private and will not be shown publicly.