Source Code Makeover: Square Shooter, Part 3

This is a continuation from Part 1 and Part 2, where I go through the source code of Square Shooter, an Asteroids clone, and try to redesign the code to be more readable.

Now that I've refactored the code, I'll try adding three new features: multiple bullets, a shotgun powerup and a UI change to show the remaining time for each powerup.

Adding support for multiple bullets

(These changes can be seen in the github history.) (Note that I accidentally checked in a lot of other files with this check in, please ignore them.)

First, the multiple bullets. I've learned from the code that the GameWorld object has a bullet member that shows the current location of the single bullet in the world. It's set to a Bullet object when there's a bullet on the map or None when there's no bullet anywhere. I'm going to change this to a list data structure, which can hold multiple Bullet objects (or be an empty list if there are no bullets on the map.) I'll rename it from bullet to bullets to reflect the multiple-bullet nature.

Any place where the bullet member is set to None (to delete the bullet) will instead either be set to [] or have a del statement called on a Bullet item in the list. Any place where the bullet member was set to a Bullet object will be changed instead to have an append() method call to add a new Bullet object to the list.

Now that there can be more than one bullet on the map, we have to change the if statement that checks if the bullet has collided with a bubble to a loop that checks each bullet for colliding with a bubble. We will change this line:

if self.bullet != None and self.bullet.collides_with(b):
...to these lines:
for i in range(len(self.bullets) - 1, -1, -1):
    if self.bullets[i].collides_with(b):

Note that in an earlier change, we modified the shoot_at() method of the Ship class to return a list of Bullet objects rather than just a single Bullet object. This was done in anticipation of this future multi-bullet feature. We need to modify the caller of shoot_at() to append all of the Bullet objects to the world.bullet list. That is where this code comes from:

if len(world.bullets) < 5:
    world.bullets.extend(world.ship.shoot_at(x / float(MAP_WIDTH), y / float(MAP_HEIGHT)))

The extend() list method is kind of like append(), except it appends all the items in a list instead of appending just one value.

The len(world.bullets) < 5 prevents new bullets from being added if there are five or more bullets currently on the map. (You can change this value to anything you want, but I wanted to prevent the player from just spamming the map full of bullets.

Checking these changes in with the git log message, "Add support for multiple bullets."

Add a shotgun powerup

(These changes can be seen in the github history.)

The shotgun powerup will have the ship fire multiple spread-out shots each time the mouse button is clicked. To get an idea of what code will be needed for this powerup, let's look at what code implements the other (freeze, super bullet, shield) powerups:

  • A 'shotgun' "kind".
  • Code that draws the shotgun powerup icon in the Powerup class's render() function.
  • A _shotgun_timer member for the Ship class to show how much time there is left for this powerup (and set to 0 when the player doesn't have this powerup.)
  • Code that decreases _shotgun_timer over time in the update() method.
  • Methods add_shotgun() and has_shotgun() for the Ship class.

These changes are pretty much just copying the code used for the other powerups. We'll need to add some custom code to the shoot_at() method so that it returns multiple bullets if has_shotgun() returns True. Here's the new code that does this (and also replaces the previous code that only created a single Bullet object.)

bullets = []
for i in range(5):
    b = Bullet()
    b.pos.copy(self.pos);
    b.speed.x = x * 3
    b.speed.y = y * 3

    # Help out the poor sods who click on their
    # own ship and get stuck with a non-moving
    # bullet. (2009-11-14)
    if abs(x) < 0.1 and abs(y) < 0.1:
        b.speed.x *= 30
        b.speed.y *= 30

    if not self.has_shotgun():
        return [b] # just return the one bullet
    b.speed.x += random.uniform(-0.15, 0.15)
    b.speed.y += random.uniform(-0.15, 0.15)

    bullets.append(b)
return bullets

Note that even though we loop 5 times, if has_shotgun() returns False, we just return the first Bullet object in a single-item list (since the caller is expecting a list of Bullet objects, not just a single Bullet object value.) Otherwise, a list of 5 Bullet objects will be returned.

Also notice that the return statement for the non-shotgun case occurs before this code:

    b.speed.x += random.uniform(-0.15, 0.15)
    b.speed.y += random.uniform(-0.15, 0.15)

This code alters the trajectory of the bullet very slightly. This is what causes the "spread" of bullets.

I'll check in this code with the log message, "Added the Shotgun powerup"

I'm going to make a slight improvement to the powerup icon for the shotgun blast by adding a couple lines so the icon looks more like the spread: Checked in with message, "Improved the shotgun powerup drawing"

Adding powerup timer UI

(These changes can be seen in the github history.)

One of the things that struck me when I first started playing Square Shooter was that I didn't know what the power ups did at all. They just had these abstract looking icons, but I didn't really know which was which or what they did (the freeze was obvious, but square around my ship didn't obviously seem like a shield.)

Also, I'm not sure how long these power ups will last. In this change, we add a User Interface that displays the powerups we have as well as how much longer we will have them. In the green sidebar, we'll add the name of the powerup along with how many seconds it has remaining.

First, we'll need to change the has_shield(), has_super_bullets(), and has_freeze() methods to return the amount of time they have left, rather than a simple boolean. WARNING! You should always be careful when changing either the parameters or the return value of a function. Anything that calls these functions might break. Be sure to do extensive testing before checking in this code.

This isn't too much of a concern for our case, because a zero value returned by these methods will be equivalent to False and a non-zero value will be equivalent to True. And also, this code is not part of a library that 3rd parties depend on. (Imagine how many people would have to change their code if Pygame decided to rename pygame.Rect to pygame.Rectangle.)

Just to make sure that a negative value is returned from these methods (a negative float value is non-zero, so it will be equivalent to True), we will also add a check for a negative value and return 0 in that case.

Here's the code that we add to the GameScreen class's render() method (which draws the other parts of the green sidebar):

text_y = 48 * 6
if self.world.ship:
    powerup_status = ((self.world.ship.has_shield(), 'Shield ' + str(int(self.world.ship.has_shield()))),
                      (self.world.ship.has_super_bullets(), 'Super Bullet ' + str(int(self.world.ship.has_super_bullets()))),
                      (self.world.ship.has_freeze(), 'Freeze ' + str(int(self.world.ship.has_freeze()))),
                      (self.world.ship.has_shotgun(), 'Shotgun ' + str(int(self.world.ship.has_shotgun()))))
    for seconds_left, text in powerup_status:
        if seconds_left:
            text = self.msg_font.render(text, False, BLACK)
            self.screen.blit(text, (MAP_WIDTH + 20, text_y))
            text_y += 25

First, we create a data structure that is a tuple of tuples which hold the number of seconds remaining for the powerup (so we can tell if the powerup is active or not) and a string with the powerup's name and the number of (whole) seconds left for it.

The rest of the code will render this text and blit it to the sidebar. The text_y variable will increment whenever it writes out some text, so that the next bit of text will be below the previous one.

Checking in these changes with the log message, Add UI for timers, modified has_[powerup]() methods to return time left.

End of Square Shooter's makeover

That's all I'll do for now. With the code organized, making new features becomes slightly easier. I'll leave you with one last bit of advice: you might not always have to go back and refactor code. Code doesn't rust if you don't modify it: the Square Shooter game worked perfectly fine without bugs before I made these changes. Refactoring is for the benefit of human programmers, not the computer. Any time you make changes (even improvements) you run the risk of adding new bugs.

You only need to go back and refactor if it will make future changes easier to do. Of course, since code almost always has bugs, you are pretty much guaranteed to need to change it. (The code, as opposed to UI and feature, changes are covered in Part 1 and Part 2.)

Either way, I hope this blog series has given you ideas on how you can write cleaner and more readable code (and what makes code "clean" and "readable".) Keep an eye out for future Source Code Makeover blog posts, and thanks for reading!

3 comments.

  1. Al, i love your python code makeovers very much. Less intimidating than looking at a stranger’s code on github and highly instructive. Please continue !

    -Horst

  2. Thanks for this Source code makeover series! It is a great read.

  3. Really love your article! And I really like the new version of game. Very sticky!

Post a comment.