Introduction
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.
Splitting code
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:
env.addParticles(5)
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:
env.addParticles(5, size=10)
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.
Displaying particles
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:
env.update()
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.
Selecting particles
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
Documentation
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:
print Particle.move.__doc__
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.
Comments (8)
Anonymous on 6 Feb 2011, 5:43 p.m.
Thanks :) Very very good tutorial
Danilo on 13 Jan 2012, 5:33 p.m.
Great Tutorial, i am following and learning a lot with it.
So, one question. In the addParticles method, isn't more interesting leave this method to only append particles to the self.particles array, and leave the control of what kind (parameters) of particle object we whish to create in the pygame program...
like: we could pass a Particle object with parameter and de function addParticle append it.
addParticle(particle):
self.append(particle)
and in the main program, we make a loop or create individuals particle objects and pass it to the function.
I think this way I wrote is more flexible to the class...
However, your tutorial is great and what I am saying is just thoughts.
bye
Peter on 22 Jan 2012, 11:09 p.m.
That would also work and seems perfectly reasonable. I prefer my method as it moves as much code as possible to PyParticles, reducing the amount of code you have to type when you use it.
If you want you can still create a particle in the main program and append it manually:
p = PyParticles.Particle(whatever parameters)
env.particles.append(p)
derek on 1 Aug 2012, 4:16 p.m.
Small bug... I believe in addParticles when y ou initialize the y position randomly, you want the random range to be from size to self.height-size (looks like a copy and paste error in code above and download where y is using self.width in random initialization).
Peter on 4 Aug 2012, 9:14 p.m.
Thanks - I've updated it.
Dan on 19 Aug 2013, 1 p.m.
Hi,
I was trying to use this module in a game I'm doing for a course, but there appears to be a bug. The easiest way to recreate is to setup the environment with two identical balls. Let the first ball be still and take the other ball and "throw" it against the first one with the mouse. Depending on which ball you throw, the two balls will either bounce like they should or stick together.
I think it has something to do with the order of collision and the speed of the balls. In the collide() function I added this to simply swap the balls:
if p1.speed > p2.speed:
pTmp = p1
p1 = p2
p2 = pTmp
Since I do not understand the mathematics in the function this is the best I can do, but it seems to fix the problem.
Thanks for the code though!
Alex on 24 Oct 2013, 3:19 p.m.
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.width-size))
"""
supposed to be ' y = kargs.get('y', random.uniform(size,
self.height-size)) ' rather than 'self.width-size' . am I right ?
"""
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)
Peter on 30 Oct 2013, 7:35 p.m.
Correct, I've fixed it, thanks.