Source Code Makeover: Square Shooter, Part 3
Mon 13 August 2012 Al Sweigart
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:
...to these lines:if self.bullet != None and self.bullet.collides_with(b):
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'srender()
function. - A
_shotgun_timer
member for theShip
class to show how much time there is left for this powerup (and set to0
when the player doesn't have this powerup.) - Code that decreases
_shotgun_timer
over time in theupdate()
method. - Methods
add_shotgun()
andhas_shotgun()
for theShip
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!