In the previous tutorials we built up a reasonable physics simulation. Now we can take using this simulation in several different directions. Rather than copy large chunks of code every time we make new simulation, it makes sense to create a separate module that can be used by all of them. In this tutorial we will:
- Convert our simulation into a module
- Rewrite our particle simulation using this module
- Introduce keyword-arguments to create flexible code
This tutorial is different from previous ones in that it is not specific to Pygame. In fact, it's not really specific to Python either: it's a general discussion on how to arrange code to maximise its reuse and flexibility. It's more philosophical (or wishy-washy, depending on your point of view), than the previous tutorials, but it's something I've had to think about on multiple occasions. I thought it might be helpful to write about what I've learnt and the thought process I go through when writing a module, though I'm far from being an expert. At the very least, it's forced me to justify my reasons for arranging the code in the way I have.
In this tutorial we are going to create two files: a PyParticles module and a python program that uses this module. I generally find it a good idea when making a module to make a separate program to test it. In fact it's better to make program that tests it more systematically (e.g. unit tests), but for now we'll just make sure we can recreate our previous simulation. Since we're not actually going to write much new code, just rearrange it into two files, it's probably easiest to download the code from the Github link at the top of this post to see the changes.
There's nothing particularly special about Python modules - they are no different from any other Python program, though generally they only contain classes and functions (they may have additional code which is used when the file is run not as a module). When creating a module, we need to think about how we expect the code to be used. On one hand we want to include as much code as we can in our module to maximise reuse, but on the other hand we want to make the code as flexible as possible, which might mean leaving out some aspects so they can be defined outside of the module.
We'll start by creating a file called PyParticles.py and adding the Particle object and the addVector(), findParticle() and collide() functions. We'll also need to import the math and random modules. But what about Pygame?
Separating particles from their display
At first glance it might seem obvious to include the code for importing, initialising and creating the Pygame screen: surely we'll always require it. But what if we (or someone else) later wants to display the particles using a different package, like PyOpenGL, or even create a command line display? Or what if you just want some code to tell you where a particle with a given position, mass and velocity will be in t units of time? More likely, what if you want to incorporate this code into a game in which you have already initialised and created a Pygame screen and now want to add the simulation to part of the screen.
In general it's a good idea to separate code that determine the behaviour of your objects (the Model) from code that determines how your objects are displayed (the View); it makes changing either one independently of the other much simpler. This is part of the Model-View-Controller architecture, only we're not going to separate the Controller (i.e. the mouse input) for now.
Our test program will therefore include all the Pygame code, so we'll start by creating a skeleton Pygame program in a new file:
import pygame import PyParticles pygame.display.set_caption('Tutorial 10') (width, height) = (400, 400) screen = pygame.display.set_mode((width, height)) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False pygame.display.flip()
We now need to remove the display() function from the Particle object. We'll move this code into the Pygame while running loop later. We can leave the Particle's colour attribute even though it is a display property because it makes sense to associate it with the particle object.
Making a container
In order to display the particles, we need to loop through a list of all the particles, but where best to put this list? It makes sense to put it in the PyParticles module, and I think it's best to put the list within a container: an object that stores all the particles and simulation parameters (such as friction and elasticity). The advantage of this approach is that it makes it easier to change parameters and to run multiple simulations at the same time if we so wish.
Below is an Environment class which will allow us to create instances of our simulation. You could even create multiple environments and display them all on the same screen. The Environment class contains the global variables from the old program, including the list of particles (note that background_colour has become simply self.colour). The idea is that when we create a simulation, we import PyParticles, create an Environment object, add particles to it and set it running.
class Environment: def __init__(self, (width, height)): self.width = width self.height = height self.particles =  self.colour = (255,255,255) self.mass_of_air = 0.2 self.elasticity = 0.75
Now let's go back to our skeleton Pygame program to test our new code.
env = PyParticles.Environment((width, height))
This code creates an Environment object called env with the same dimension as the Pygame screen. If we wanted we could use different dimension, which would allow to add other elements to the screen.
Adding particles to the environment
Now we've set up the environment, we want to add some particles to it. For this we add an addParticles() function to our Environment class. To decide how we want this function to behave, I find it helps to imagine how we want to call it. For example, we probably want be easily add, say, 5 random particles with a call like:
But what if we want our particles to all be the same size? It would nice if we could get size particles of size 10 by simply writing:
And what if we want more control? For example, to follow the trajectory of a particle with specific properties. Ideally we just want to write as little as possible:
env.addParticles(x=5, y=10, speed=1.5, angle=0.8, size=4, mass=1000)
This means using keyword arguments: a list with an arbitrary number and type of parameters. To the Environment class, add:
def addParticles(self, n=1, **kargs): for i in range(n): size = kargs.get('size', random.randint(10, 20)) mass = kargs.get('mass', random.randint(100, 10000)) x = kargs.get('x', random.uniform(size, self.width-size)) y = kargs.get('y', random.uniform(size, self.height-size)) p = Particle((x, y), size, mass) p.speed = kargs.get('speed', random.random()) p.angle = kargs.get('angle', random.uniform(0, math.pi*2)) p.colour = kargs.get('colour', (0, 0, 255)) p.drag = (p.mass/(p.mass + self.mass_of_air)) ** p.size self.particles.append(p)
To create a function that uses keyword arguments, we add **kargs to the end of the parameter list. This will generate a dictionary called kargs of any passed keywords. We can then use get(keyword, default) to search the dictionary for specific keywords, and use a default value if it's not present. Note that you'll now need to import the math and random module into this module to get the default random values. I tried to pick default values that I thought would be most useful.
I also made, n, the number of particles to be added optional, so if you leave it out the function call, the default behaviour is to create one particle.
Now we can go back to our skeleton Pygame program and to add code to display the particles. It's pretty simple, we just add the following with the loop:
screen.fill(env.colour) for p in env.particles: pygame.draw.circle(screen, p.colour, (int(p.x), int(p.y)), p.size, p.thickness)
This fills the screen with the colour of the environment (currently set to white), then loops through the environment's particles, drawing circles to represent them. Now we just need to get the particle to move. Ideally, we'd like to do this with a simple call:
Updating the simulation
The environment update method is essentially the remaining code from the main loop in tutorial 9. To the Environment class add:
def update(self): for i, particle in enumerate(self.particles): particle.move() self.bounce(particle) for particle2 in self.particles[i+1:]: collide(particle, particle2)
You'll notice a couple of differences. First, we're not calling a display method since we've dealt with that elsewhere. Second, we've changed particle.bounce() to self.bounce(particle). The reason is that it makes sense to make bounce a method of the environment, since it depends on where the boundaries of the environment are and width, height and elasticity are no longer global variables, but belong to the container. To make the code work, you have to move the bounce() function from the Particle class to the Environment class and change width, height and elasticity to self.width, self.height and self.elasticity respectively.
I decided against moving the collide function into the Environment class as it didn't make much sense and theoretically we could create particles and test for collision without needing an environment container. However, there are a couple of line that refer to elasticity, so we need to change these:
elasticity = p1.elasticity * p2.elasticity p1.speed *= elasticity p2.speed *= elasticity
Then we need to give the Particle class an elasticity attribute, which I set to 0.9. This gives us more flexibility to have particles and walls with different elasticities.
Finally, we need to add back the ability select and drag particles about. This is part of the controller in the MVC system, so shouldn't be part of the PyParticles module. Instead the following should go into the event handling code in the test program:
elif event.type == pygame.MOUSEBUTTONDOWN: selected_particle = env.findParticle(pygame.mouse.get_pos()) elif event.type == pygame.MOUSEBUTTONUP: selected_particle = None
Then add the findParticle() function into the Environment class remembering to change particles to self.particles.
Then, below the event handling code, add:
if selected_particle: selected_particle.mouseMove(pygame.mouse.get_pos())
To minimise the amount of code we need to write each time, I created a mouseMove() function to contain the code:
def mouseMove(self, (x, y)): dx = x - self.x dy = y - self.y self.angle = 0.5*math.pi + math.atan2(dy, dx) self.speed = math.hypot(dx, dy) * 0.1
Since we've made a module that other people can use it's a good idea to add some comments to explain the classes and functions. If you've already add comments then that's great - I should have earlier, but was being lazy. Use triple-quoted comments at the beginning of functions to create a documentation string. This will be displayed by some IDEs and can be extracted by, for example:
You can find the final code by clicking the Github link at the top of this post and that includes documentation strings.
We've created a fairly flexible module that allows us to recreate our simulation with very little code (just 34 lines, including blanks ones). However, there is still one major inflexibility in the module in terms of what behaviours the particles display. For example, at the moment, the code for making particles fall under gravity is commented out; if we uncomment it, then any simulation using PyParticles will be effected (and crash as gravity is no longer defined). In the next tutorial, we'll deal with this problem and generalise it for all particle behaviours.