What is Density?
This laboratory exercise will give us more experience creating classes, objects and methods. We will also explore how using Pygame’s ‘Sprite’ class allows us the ability to more easily manipulate groups of object. Lastly, after creating the ‘Density’ game, you will understand the basic programming framework most video games fall into.
Take a look at the video to the right for a preview by Blake Castleman and William Cockrum.
Let’s get started!
Now Try
- Create a title screen which tells the player to press enter to begin.
- Make the window 1200 pixels wide and 670 pixels high.
- Declare global variables that represent the colors we will use in our game. For example, BLACK should be set to (0, 0, 0).
The Sprite Class
Before we start coding ‘Density’, let’s first talk about the general order of operations when creating a game. If you look back on the work we did building “Pong”, you could see that in order to build the game, we had to accomplish the following tasks:
1. Initialize game variables. 2. Create game objects. 3. Use a game loop. a. Update all the game objects. b. Blit all the game objects.The above framework is fine for a game of “Pong” because there are so few game objects that are involved, namely the two ‘Paddle’s and single ‘Ball’. However, think about how much more complicated this process would be if you had 50 'Ball's at the same time that you had to manage in the game loop. This is the exact problem we are presented with when building “Density”.
The developers of ‘Pygame’ are aware of this problem and have developed a class called ‘Sprite’ which will allow us to place game objects into ‘groups’. By placing sprites in groups, we will be able to run the update method of all the objects in a ‘group’ with one command. Furthermore, we will be able to blit the surface images of all the game objects in a ‘group’ with one command. Remember that the update function is used to make changes to the position and appearance of our sprites and the blit method is used to communicate those changes visually to the user by placing it on the surface which will eventually be burned to the screen.
Following the framework allowed to us by the ‘Sprite’ class, our set of tasks when creating a game changes to the following:
1. Initialize game variables. 2. Create groups. 3. Create game objects and add them to the respective group. 4. Use a game loop. a. Update groups. b. Draw groups.First, we need to initialize all the variables we need to run our game. We may have global variables that contain various values such as the size of the screen and the different colors we will be using. We need to create and initialize those first. Next, we will also need to create and initialize the variables that our game will be using within our main function. Examples of these variables would be ‘screen’, ‘clock’ and fps. We need to do that before we start coding anything related to the functionality of our game.
The second stage of the order of operations is creating our game objects. In “Pong”, these game objects were the ball and paddles. In our “Density” game, these will be the two players. We don’t need to worry about creating the ‘pills’ just yet, because the ‘pills’ will be added dynamically in the game loop. We only need to worry about the objects that need to be created before our game runs.
The third stage of the process is to create groups for our game objects. In “Density”, we will be creating groups for our players, which we will call ‘ships’, the ‘pills’, and lastly, the text objects for our HUD. A ‘Group’ is a class that is a part of the ‘Sprite’ class which allows game objects to be grouped together. Grouping objects together is convenient because we can blit all the objects in a group to the main screen with a single command. Likewise, we can invoke the update method on all the objects in a group with a single command. We will speak more about ‘Groups’ later in this tutorial.
The last portion of the order of operations is to have a game loop and to use the ‘Group’ functionality to invoke the update method on all the objects in a ‘Group’. Lastly, we will use the draw functionality of ‘Groups’ to blit all the objects in our groups to the main screen.
This is the structure that ALL video games have and this will be the explicit structure we will use to create ‘Density’ and all future games using Pygame. As a side note, if you look at your ‘Pong’ game, you can loosely see that this is the same process that we went through although we didn’t utilize the available functionality of Pygame’s ‘Sprite’ class.
Let's Start Coding!
Let’s modify the code that we currently have so that it incorporates the following comments:
# Constants WIN_W = 1200 WIN_H = 670 BLACK = (0, 0, 0) WHITE = (255, 255, 255) SHIP_WIDTH = SHIP_HEIGHT = WIN_W/120 # Classes def main(): # Initialize variables # Create Game Objects # Create Groups # Intro Loop # Game Loop while play: # Checks if window exit button pressed for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() # Keypresses elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: pygame.quit() sys.exit() # Update Groups # Adding Pills # Print Groups # Limits frames per iteration of while loop clock.tick(fps) # Writes to main surface pygame.display.flip() if __name__ == "__main__": # Force static position of screen os.environ['SDL_VIDEO_CENTERED'] = '1' # Runs imported module pygame.init() main()
Create the Ship class
Great, we have an outline of our general structure in place. Now, we just need to code each portion. Let’s start by creating a class for our ships.
Add the following code to the top of your program under “# Classes”:
class Ship(pygame.sprite.Sprite): def __init__(self, x, y, player): pygame.sprite.Sprite.__init__(self) self.player = player self.speed = 5 self.image = pygame.Surface((SHIP_WIDTH, SHIP_HEIGHT)).convert() self.rect = pygame.Rect(x, y, SHIP_WIDTH, SHIP_HEIGHT) def update(self): key = pygame.key.get_pressed() if self.player == ‘left’: if key[pygame.K_w]: self.rect.y -= self.speedYou can see that we have a class called ‘Ship’, which will be our player whose purpose in life will be to eat all the pills it can and gain a density of 15,000 before it’s opponent. In the class definition above, you should notice something that you haven’t seen before in the following line:
class Ship(pygame.sprite.Sprite):What ‘pygame.sprite.Sprite’ specifies is that the ‘Ship’ class is inheriting from the built-in ‘Sprite’ class. What inherit means is that all the properties and methods of the ‘Sprite’ class are now available to our ‘Ship’ class. What this effectively means is that when we create an instance of ‘Ship’, we can use the built-in functionality that other developers have created for everyone to use. In particular, we will be using the method ‘spritecollide()’ which will allow us to determine if two sprites are touching and take appropriate action.
To bring home the power of the ‘Sprite’ class, if we used the ‘spritecollide()’ method in our “Pong” game, it would have saved us the trouble of writing all the logic that determined if the ball collided with the paddle. We could have just run ‘spritecollide()’ when updating the ball and changed its x-value if a collision with the paddle had been detected. Yes, I know, you must hate me right about now.
Moving the Ships
class Ship(pygame.sprite.Sprite): def __init__(self, x, y, player): pygame.sprite.Sprite.__init__(self) self.player = player self.speed = 5 self.image = pygame.Surface((SHIP_WIDTH, SHIP_HEIGHT)).convert() self.rect = self.image.get_rect() self.rect = self.rect.move(x, y) def update(self): key = pygame.key.get_pressed() if self.player == ‘left’: if key[pygame.K_w]: self.rect.y -= self.speedMost of the properties defined in the ‘Ship’ class should be self-explanatory, however ‘player’ might cause some confusion. ‘player’ is a value that will be passed to the constructor when creating an object of type ‘Ship’. ‘player’ will have a value of left or right, signifying which side of the screen they will be playing on.
You can see in the update method that I have structured the code that moves the ship a bit differently from how we did it in “Pong”. Instead of specifying the values for ‘up’, ‘down’, ‘left’ and ‘right’ according to their corresponding keyup and keydown values, we are going to simplify the algorithm to move the rect’s x and y values.
Let’s complete our definition for the ship’s movement in the update method in the ‘Ship’ class. Remember that we can’t test our algorithm until we write the code that creates an instance of ‘Ship’ and updates and draws the ship.
Now Try
- Complete the branch statement that defines the ‘Ship’ class’ movement.
- Use ‘wsad’ for forward, down, left and right. Remember that we are not worried about when the user releases the key, simply when the key press is registered.
Creating an Instance of Ship
Let’s now get our program into a state where we can test the code we just wrote in our ‘Ship’ class.
def main(): # Initialize variables … # Create Game Objects # Create Groups # Intro Loop … # Game Loop while play: # Checks if window exit button pressed for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() # Keypresses elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: pygame.quit() sys.exit() # Update Groups # Adding Pills # Print Groups # Limits frames per iteration of while loop clock.tick(fps) # Writes to main surface pygame.display.flip()Looking at our outline above, I can imagine that you have added code to both the ‘initialize variables’ and ‘intro loop’ sections. Let’s add to your work and create an instance of the ‘Ship’ class. We can do it by calling the constructor for class ‘Ship’ and passing it the appropriate arguments, namely, the beginning (x, y) position of the ship and the side of the screen the ship should appear on. Add the following code to your program:
... # Create Game Objects ship_left = Ship((WIN_W/4) - (SHIP_WIDTH/2), WIN_H - (SHIP_HEIGHT * 4), 'left') ...Notice, that I defined the (x, y) starting position of ship relative to the width and height of the screen. This was done because I would like everything to stay in relatively the same position whether the screen is set to a resolution of 800x600 or 1024x786. One way to accomplish this is to make the size of EVERYTHING a function of the width and height of the screen.
Notice that in order for the code above to work, you will need to define the global variables ‘SHIP_WIDTH’ and ‘SHIP_HEIGHT’. If you are following the paradigm stated in the above paragraph, you will define these variables to be a function of the width and height of the screen.
Now Try
- Create the global variables ‘SHIP_WIDTH’ and ‘SHIP_HEIGHT’.
- Set their values to a fraction of the width and height of the screen.
Creating a Ship Group
Great, we created our ship object by running the constructor found in the class definition for ‘Ship’. The next section in our order of operations is ‘Create Groups’. Let’s continue to feel the power of the ‘Sprite’ class and create a “ship_group” with the following commands:
# Initialize variables … # Create Game Objects ship_left = Ship((WIN_W/4) - (SHIP_WIDTH/2), WIN_H - (SHIP_HEIGHT * 4), 'left') # Create Groups ship_group = pygame.sprite.Group() ship_group.add(ship_left) # Intro Loop …
Running Update
Now, let’s demonstrate the power of the ‘Groups’ and update and print the ship game object inside the ‘ship_group’ with the following commands:
# Game Loop while play: # Checks if window exit button pressed for event in pygame.event.get(): if event.type == pygame.QUIT: sys.exit() # Keypresses elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: pygame.quit() sys.exit() # Update Groups ship_group.update() # Adding Pills # Print Groups screen.fill(WHITE) ship_group.draw(screen) # Limits frames per iteration of while loop clock.tick(fps) # Writes to main surface pygame.display.flip()Run your program and you should see that your ship is printed on the screen as specified in the ‘Ship’ class’ constructor. Further, the ship’s position is being updated according to the keystrokes registered by the user by calling ‘ship_group.update()’. Lastly, the ship’s updated position is reflected onscreen by calling ‘ship_group.draw(screen)’. Fantastic, now let’s get another ship on the screen.
Now Try
Write the necessary code so that instead of just having one ship on the screen, we have two ships. One ship should start in the middle of the left side of the screen and the other in the middle of the right side of the screen. Be sure to use up, down, left and right arrows to control the ship. The code to detect up is ‘pygame.K_UP’.
Splitting the Screen
We are off to a great start. We have two ships that can be controlled independently and we are using the ‘Sprite’ class to do it. Now, we need to divide the screen using a vertical line that will delineate the left side from the right side. We also need to create a horizontal line that will separate the HUD from the area the ships can move.
Let’s start by creating the vertical line. We can do this by creating a Surface that is one pixel in width and blit it to the screen at ‘WIN_W/2’. Add the following line to your code under “# Create Game Objects”:
# Create Game Objects … vert_partition = pygame.Surface((1, WIN_H))Now that we created the vertical partition, we simply need to blit it to the screen. Typically, we would add every game object to a group and use ‘some_group.draw(screen)’ to blit them to the screen. However, since the vertical partition doesn’t need to ever be updated, we can skip that step and just blit them to the screen by adding the following line of code:
# Print Groups screen.fill(WHITE) shipGroup.draw(screen) screen.blit(vert_partition, (WIN_W/2, WIN_H/15))Notice here that the method ‘blit’ is being passed two arguments, the first is the x position it will be drawn to and the second is the y position it will be placed at. We are passing ‘WIN_W/2’ to get the line in the middle of the screen and we are passing ‘WIN_H/15’ so that we leave some room at the top of the screen for the HUD. Run your program and you will see that now you have a vertical line separating the screen in half.
Now Try
Create a horizontal line object that will separate the two ship’s worlds and blit it to the correct position at the top of the screen. Remember, since you don’t need to run update on this object, we should not create a group for it. You simply need to blit it to the screen.
Defining Boundaries
When you run your game, you should notice that there is a small problem with the two ships: they can run into the HUD area and they are able to fly off the screen. Let’s fix that by defining the boundaries the ships can move within. Add the following code to the update method of the ‘Ship’ class:
def update(self): # Ship Movement … # Keep Ship Movement Inbounds if self.rect.y < WIN_H/15: self.rect.y = WIN_H/15Taking a look at the code above, we can see that we have the algorithm that adjusts the ship’s rect.x and rect.y according to the key presses registered by the user. However, by adding the specified conditional statement, we are making sure that if the ship moves up past the HUD, that we are reassigning the ship’s rect.y value to ‘WIN_H/15’. This will keep the ship from passing up into the HUD and defines the upper boundary within which it can move.
Now Try
Add to the branch and create a lower limit on the y-axis within which the ship can move. Remember that you can’t simply set the lower limit to ‘WIN_H’ or the ship will be allowed to move off the bottom of the screen and away from view. Use the ship’s height when setting the lower limit.
Both your ships should now not be able to fly up past the HUD or down past the lower edge of the screen. However, both ships can still fly without a boundary on their left and right sides. Let’s set a boundary for the x-axis of the ship. Add the following code to the update method of your ‘Ship’ class:
def update(self): # Ship Movement … # Keep Ship Movement Inbounds … if self.player == 'left': if self.rect.x < 0: self.rect.x = 0Notice that when we are defining the boundaries on the x-axis for our ships, we must specify whether we are dealing with the left or right ship since they have distinct x-axis boundaries. For example, the left ship should have a left boundary of ‘x = 0’, while the right ship should have a left boundary of ‘x = WIN_W/2’. This brings us to one of the reasons we specified the parameter ‘player’ when writing the constructor for our ‘Ship’ class. We need to know which ship we are dealing with so we can set their x-axis boundaries accordingly.
Now Try
- Complete defining the boundaries on the x-axis for the left player.
- Write the x-axis boundaries for the right player.
Adding Pills
The next element we want to add to the game are falling pills of different colors, which signify different density values. We could do this many different ways, however, I chose to create an array of random tuples (x, y), where x defines the x coordinate at which the pill will be placed and y defines its density value. We then will use this array of tuples to place objects of type ‘Pill’ onto the screen.
To get started, we will need to create a function called ‘gen_random()’ that will generate the array of random tuples. Add the following code to your program:
class Ship(): … def gen_random(): xval_density = [] for _ in range(3000): xval_density.append(( random.randrange(0, (WIN_W/2) - PILL_WIDTH), int(random.choice('1111111111111111111122222334')))) return xval_density def main(): # Initialize variables ... xval_density = gen_random() max_pill_count = len(xval_density)Taking a look at ‘gen_random()’, we can see that an array is created called ‘xval_density’ and it is being filled in the for loop that runs 3000 times. Inside the for loop, we are appending a value type tuple in the form (x, y). The x value is a random number from 0 to the middle of the screen. This will tell us where to place the pill. The y value in the tuple is a random number from 1 to 4, where ‘3’ will appear twice as many times as ‘4’, ‘2’ will appear twice as many times as ‘3’, and ‘1’ will appear ‘5’ times as much as the number ‘2’. This makes sense because we want the higher value pills to be rare and the lower value pills to be plentiful to encourage the player to chase after the higher value pills.
Looking at the method the x value of the tuple is generated, we can see that we are using ‘random.randrange(x, y)’. We should be familiar with this method as we used it many times during our Turtle exercises.
Looking at the method the y value of the tuple is generated, we can see that we are using the method ‘random.choice()’. Looking at the documentation for this method here, we can see that it returns a random element of a sequence. That begs the question: What is a sequence? In Python, a sequence can be a list, a tuple and a string. In our case, we are using a string for no good reason. Note that it would be trivial to use a list or tuple in place of a string. Looking back at our code, we can see that the second value of the tuple we are appending is a random character in the string '1111111111111111111122222334' and serves our purpose.
One thing to note is that for obvious reasons, we should define the global variables ‘PILL_WIDTH’, ‘PILL_HEIGHT’, ‘YELLOW’, ‘RED’, and ‘BLUE’. Let’s set them to ‘7’, ‘25’, ‘(255, 255, 0), (255, 0, 0), and (0, 0, 255) respectively.
Try running your code. You will notice that nothing has changed and there are no falling pills. Of course, that’s because we have only generated an array that contains the information that defines where our program should place the pills. We still need to define the ‘Pill’ class, create instances of ‘Pill’ and finally blit them onto the ‘screen’. However, before we do that, let’s check that our ‘gen_random()’ function is working by printing its contents. Remember that ‘gen_random()’ must contain x values between 0 - (WIN_W/2-PILL_WIDTH) and y values between 1 - 4. We can do this simply with the following line of code:
def main(): # Initialize variables ... xval_density = gen_random() max_pill_count = len(xval_density) print xval_densityWe have generated our ‘xval_density’ array, now it’s time to define our ‘Pill’ class. Use the following class structure:
class Pill(pygame.sprite.Sprite): def __init__(self, xval, density): pygame.sprite.Sprite.__init__(self) … def update(self): passNow we need to consider what properties our ‘Pill’ should have. We could define a large set of properties if we wanted to, but following the programmer’s law of simplicity (is there such a law?) we can see that the pills have no other function that to be a rectangle and fall down the screen. To actualize these two characteristics, we simply need a speed property, an image, and its associated rect.
Now Try
- Define the ‘speed’ property. I have found that 5 is a decent value that results in a challenging rain of pills.
- Define the ‘image’ property. Be sure to use the global variables ‘PILL_WIDTH’ and ‘PILL_HEIGHT’ to define its size.
- Define the ‘rect’ property. Be sure to invoke the ‘move()’ method using the parameter ‘xval’ to ensure your pills are placed at random locations on the x-axis.
- Define the ‘density’ property. Remember this property should be initialized using the 'density' parameter.
- Define the update method so that our pills fall down at a rate equal to the ‘speed’ property. For a reference on how this is done, look at how the paddles were moved in ‘Pong’ when the user pressed the up and down keys.
Blitting Pills
We have spent all this time defining our ‘Pill’ class, but we haven’t been able to test anything because we still haven’t created an instance of ‘Pill’. As well-seasoned programmers, that should make you anxious. Let’s create instances of ‘Pill’!
Our pill objects are a bit different from all the other game objects we have created so far in that they are created dynamically. What that means is they aren’t created before the game loop, they are created in the game loop. As the game runs, pills are created, placed on the screen and have their y values incremented which will result in a falling pill. Add the following code to your game loop:
# Game Loop while True: # Checks if window exit button pressed … # Update Groups … # Add Pills if pill_count < max_pill_count and TIMER % 10 == 0: pill = Pill(xval_density[pill_count][0], xval_density[pill_count][1]) pill_group.add(pill) pill_count += 1 # Print Groups … TIMER += 1 # Limits frames per iteration of while loop clock.tick(fps) # Writes to main surface pygame.display.flip()Looking at the changes above, we can see that we needed to add two counters to our program: ‘pill_count’ and ‘TIMER’. ‘pill_count’ will keep a running count of how many pills have been added to the screen. We need this information so that we don’t add more pills than we have information for. The ‘TIMER’ variable is going to be used as a timer that limits the number of pills that appear on the screen per second.
Looking at the second condition of the branch statement, we can see that the pills added to the screen are being limited by the TIMER. Remember that the TIMER is being incremented every iteration of the game loop, and the game loop is being run at ‘fps’ number of times a second. Effectively, this means that TIMER is being incremented 60 times a second. Since the block of code that adds pills to the screen is being run in the game loop, there will be 60 pills added to the screen every second. Because of this reason, we add the conditional expression “TIMER % 10 == 0” to limit the number of pills added to 6 per second. The reason we make the TIMER variable global is that it will be useful later as we add additional functionality to our game.
Try running your program. You will see that nothing happens. This is because although we are adding our dynamically created pills to ‘pill_group’, we haven’t even created ‘pill_group’ yet! Furthermore, we need to update the group of pills and draw them to the screen.
Now Try
- Create the ‘pill_group’ variable the same way you created ‘ship_group’, by calling the Sprite class’ ‘Group()’ method. Remember we already wrote the code that adds the new pills to the group.
- Update the group of pills by using the ‘update()’ method and draw the pill’s images to the screen by invoking the ‘draw’ method. Remember to place them under the appropriate section delineated with the corresponding comments.
Now Try
- Add to your algorithm so that each time a pill is created for the left side of the screen, there is another pill created with the same x value and density for the right side of the screen. Remember that both ships should receive the same configuration of falling pills.
Too Many Pills
Have you ever wondered what happens to the Pill objects after they fall off the bottom of the screen? Well, if you could imagine,
they continue to happily fall down off your screen dimensions. Pygame is continuing to update and draw the sprites in the pill_group!
These are unnecessary calculations and we should remedy the situation.
A solution is simple, use the Sprite class' kill() method to remove the pill from the group. By removing the pill from the group, it still exists, but it isn't updated, nor is it blitted. Essentially, the pill doesn't cause your program any wasted calculations as the game loop iterates. Although the kill() method doesn't erase the pill object from memory, it's ignored which is close enough. Let's start by taking a look at the following line of code:
self.kill()It's simple enough. If this instruction is executed in a method in the Pill class, self refers to the pill and the call to kill() will remove it from the pill_group. Now the question is, where do you put this line of code and under what condition should you run it?
Now Try
- Add a invocation of the kill() method.
- Place the invocation to kill() inside of a conditional statement so that pills are only removed from the group when they leave the screen.
Colored Pills
Now that we have two ships with two sets of raining pills, we need to get our pills color coded so that the user knows which pills have a higher density.To color code our raining pills so that the density value is clear to the players, we can set the color of the pills by simply adding the following line to the constructor of our ‘Pill’ class:
self.image.fill(YELLOW)If you run your program now, you will notice that all your pills are yellow. This is because when we create our pills dynamically in our game loop, the constructor is being called to create each one. When the constructor is being called, it runs the ‘fill()’ method and fills the pills image with the color yellow.
This is a great way we can get our pills to be a certain color, but we will need to add some logic that figures out which color each pill should be based on the density value. Let’s replace the command above with the following command:
self.image.fill(self.set_color(density))Comparing the two lines of code, you will notice that the only difference is that instead of simply setting the fill color to yellow, we are now calling a built-in method that takes in the density value. This method we call ‘set_color()’ will contain the logic that will determine which color to return based on the value contained in the property ‘density’.
Now Try
Code the logic for ‘set_color()’. Remember that a density value of 1 is represented by the color yellow, 2 by a red, 3 by a blue and 4 by a black.
Fantastic! Now you have a game that contains raining pills of different colors!
Eating Pills
The logic needed in order for the ships to eat the pills, can be quite complicated to code. Lucky for us, our ‘Ship’ and ‘Pill’ class are both inheriting from the ‘Sprite’ class! This means that our ships can use the method ‘spritecollide()’, which is a built-in method of the ‘Sprite’ class, to check if a pill object is touching a ship object. Add the following code to the update method of your ‘Ship’ class:
def update(self, pill_group): … collisions = pygame.sprite.spritecollide(self, pill_group, True) for key in collisions: self.density += key.densityTaking a look at the code above, you should be confused by what you see. What is happening is that the collision variable is being set to a list of objects that the ship has collided with. This usually is nothing, occasionally one pill, and very rarely it will be two pills that have been eaten at the same time. The way our game is constructed at the moment, it’s impossible for a situation to arise where the ship will eat more than two pills at a time.
Now, let’s look at the method ‘spritecollide()’. The first argument ‘self’ is the left or right ship, depending on which is invoking the ‘update’ method. The second argument ‘pill_group’ is an object containing all the pills. The last argument is really helpful. If we pass it the value ‘True’, on a detected collision, the pill object will be removed. On the flipside, if we pass it the value ‘False’, a collision will not cause a removal of the object that has been collided with.
Looking at the for loop, we can see that ‘key’ is scrolling through the different objects the ship has collided with. Usually, ‘collisions’ will be empty and the for loop won’t even iterate. When the ‘collision’ variable has an object(s) in it, the for loop will iterate and ‘key’ will contain the object that has been collided with, namely the pill. What we are going to do with the pill is take its density value and add it to the ship’s density property. In effect, this will increase the ship’s score based on which pills they have eaten.
Now Try
- Create a density property in the ‘Pill’ class and initialize it. Remember that a pill has a designated density value that it should be initialized to. Be sure to set the density value to the correct number.
- Create a density property in the ‘Ship’ class and initialize it. In order to initialize the ship’s density property to the correct value, ask yourself how many pills has the ship eaten when the game first begins?
Passing Arguments to Parameters
Great, you have created a density property for both the ‘Pill’ and ‘Ship’ class, however if you run your program you will run into the following error:
TypeError: update() takes exactly 2 arguments (1 given)The reason this error is coming up is that in the ‘Ship’ class definition, we have redefined the ‘update()’ method to have the parameter ‘pill_group’. We need ‘pill_group’ so that the ship can receive the list of pills that have collided with it and take appropriate action. Since we defined ‘update()’ with a parameter, we need to pass the ‘pill_group’ argument to the invocation of the method in ‘main()’. Let’s make that addition now:
# Gameplay while True: … # Update Groups ship_group.update(pill_group) … # Print Groups …Run your program and you should have pills raining down each color coded based on their density values! Furthermore, because we incremented the ship’s ‘density’ property using the code below:
for key in collisions: self.density += key.densityOur ship’s density is being incremented based on the density value of each pill it eats. In effect, each ship has a running tally of its current density!
Densify
If you read the game description at the beginning of this tutorial, you will see that the purpose of ‘Density’ is to eat as many pills as possible so that your ship’s density goes up. As a ship’s density value goes up, it’s dimensions increase kind of like a black hole’s mass and size increase as it continues to suck up stars, planets, and dark matter among other things. We will now code the increasing size of our ship.
Let’s start by adding a method call to ‘grow_ship()’ to the update method of our ‘Ship’ class:
def update(self, pill_group): … self.grow()
Now Try
- Define the grow function so that the ship’s dimensions grow based on the value of the density property.
- The length of the ship’s sides should be equal to the square root of the density value.
- To modify the ship’s dimensions, you will need to use the ‘scale()’ method: pygame.transform.scale()
Growing Ship
You may notice that your ship’s size isn’t increasing even though you are eating a ton of black pills. Try printing out the density, width and height of the ship as it’s eating the pills. What do you notice? You should notice that the width and height are increasing, however the density value is increasing at such a low rate that it’s growing size is imperceptible.
Let’s fix that. Remember in the game description, each pill was worth 50, 100, 150, and 200? We need to make sure that when we add to the density of our ship, we are making a conversion so that the correct value is being added to the density of our ships. If our ship’s density values are increasing by multiples of 50, you will see your ships growing significantly as they eat pills.
Now Try
Adjust your algorithm so that the density value of the pills that is being added to your two ships increases according to the game description.
Heads Up Display(HUD)
You have the ship’s density value increasing and it’s size growing. Now, we can work on displaying the density information to the players in the space we carved out for our HUD. If you remember back on our time constructing “Pong”, a HUD is simply a string that contains a score variable that is incremented. Then the string is blitted to the screen. For “Density”, we are going to do the same thing, but we are going to use a group to make updating and blitting easier. Also, we are going to display score information for both our ships which adds a bit more complexity to our “Text” class definition when using groups.
Copy and Paste
The algorithm for displaying our statistical information is a bit complicated, so I will simply give you the code and explain how it functions. Let me say before we start, the HUD could have been completed with just one class if we simply needed to display one player’s density or if we didn’t use the ‘Sprite’ class’ group functionality.
However, since we need to display two scores and we will be adding each score object to a ‘score_group’ to make the updating and blitting easier, we have more complexity when defining our classes. Something to note, is that although these class definitions are complex, once you understand what is going on, you will be able to use the same technique when creating a HUD for any other game regardless of the number of players or the amount of information needed to be displayed.
Class Definitions
Ok, let’s get started. Add the following class definition to the top of your file:
# Constants … class Text(pygame.sprite.Sprite): def __init__(self, player size, color, position, font): pygame.sprite.Sprite.__init__(self) self.player = player self.color = color self.position = position self.font = pygame.font.Font(font, size) def update(self, left_ship_density, right_ship_density): if self.player == “left”: text = "mass: " + str(left_ship_density - 169) else: text = "mass: " + str(right_ship_density - 169) self.image = self.font.render(str(text), 1, self.color) self.rect = self.image.get_rect() self.rect.move_ip(self.position[0]-self.rect.width/2, self.position[1])
Creating Score Objects
Now that we have our Text class definition, let’s use it by creating an instance of ‘left_score’ and ‘right_score’:
# Create Game Objects ... left_score = Text(“left”, 40, BLACK, (WIN_W/5, 10)) right_score = Text(“right”, 40, BLACK, (WIN_W/1.25, 10)) # Create Groups ... score_group = pygame.sprite.Group() score_group.add(left_score, right_score) # Gameplay Loop ...We have created our left and right score objects, now let’s run update and draw on them in the game loop:
# Update Groups ... score_group.update(ship_left.density, ship_right.density) # Add Pills ... # Print Groups ... score_group.draw(screen)Run your program and you should see that the density values for each ship are now displayed on the screen in the HUD! Fantastic, but as we should realize by now, as programmers, it is vastly more important that we understand why an algorithm works as opposed to simply having an algorithm that works. Programmers follow the mantra: “I don’t care that it works. I need to understand why it works!”
Control Flow Sequence
Let’s go over the algorithm starting with the creation of the ‘left_score’ object which is an instance of “Text”. We are passing the constructor of “LeftScore” four arguments: left/right, a font size, the font color, and the (x, y) location the text should be blitted to. Notice that for ‘left_score’, we are placing it in the middle of the left half of the screen and we are placing ‘right_score’ in the middle of the right side of the screen.
Remember that when an object is created, its constructor is called to initialize all the object’s instance variables, so the control flow of the program jumps up to the class definition of “Text”:
class Text(pygame.sprite.Sprite): def __init__(self, player size, color, position, font): pygame.sprite.Sprite.__init__(self) self.player = player self.color = color self.position = position self.font = pygame.font.Font(font, size)
Back to ‘main()’
The constructor of the base class ‘Text’ creates and initializes the properties of the ‘left_score’ object and when it’s done, the control flow jumps back to the constructor jumps back to the ‘main()’ function to where the ‘left_score’ object was created and continues executing code, namely the creation of the groups:
# Create Groups ... score_group = pygame.sprite.Group() score_group.add(left_score, right_score)Here, we are creating a group for the score objects and adding the score objects to the group.
Update and Draw
Entering into the game loop, we run the following update command:
# Update Groups ... score_group.update(ship_left.density, ship_right.density)When this command is executed, the update method of each objects in the ‘score_group’ are run starting with the first one. Since we have two objects in the group, it runs the update method of ‘left_score’ followed by the update method of ‘right_score’. Notice that the group update method is passed the densities of both ships as arguments.
Upon running “update”, the control flow of the program jumps up to the update method of the class definition for “Text”:
class Text(pygame.sprite.Sprite): ... def update(self, left_ship_density, right_ship_density): if self.player == “left”: text = "mass: " + str(left_ship_density - 169) else: text = "mass: " + str(right_ship_density - 169) self.image = self.font.render(str(text), 1, self.color) self.rect = self.image.get_rect() self.rect.move_ip(self.position[0]-self.rect.width/2,self.position[1])Here, we can see that the first command is a branch statement that determines which score is being updated and running font.render with the appropriate density value, namely the left ship’s density. Then the control flow goes back to the main program where the command ‘score_group.update(ship_left.density, ship_right.density)’is executed.
Once the control flow returns to this line of code, the program runs the same algorithm with the second object in ‘score_group’ which happens to be ‘right_score’. The entire sequence is repeated, this time updating the text on the right side of the screen which displays the right ship’s density.
The next instruction that is executed is the following line:
# Print Groups ... score_group.draw(screen)The draw command takes each object in the “score_group” and blits it’s image at it’s rect on the “screen”. This call to draw starts by blitting the left_score object to the screen using its image and rect, then blits the right_score object as well. Simply running the instruction above is all that is needed to blit all the objects in the group to the screen. Viola, this set of instructions is then repeated in the game loop and we have the score updating and being drawn to the screen throughout the course of the game.
If you understood the description of the control flow sequence above, then you have a good handle on how ‘Sprite’ groups handle ‘updates’ and ‘draws’ for a group containing more than one object. If you didn’t understand the description above, I would strongly suggest that you re-read it after becoming more familiar with the algorithm and my words become meaningful.
Now Try
- Create a density limit at which point the game ends and a winner is declared. Before a winner is declared, have the winner’s ship increase in size until they fill the screen, while the loser decreases in size until they disappear.
- Create an outro screen that states the winner and asks the user to click on the screen if they would like to play again.
Congratulations, you have completed the game ‘Density’!