Python 024 - Help
Let’s add the help messages to the GAME OVER screen, and, time permitting, work on quarter insertion. I expect the first bit to be mostly tedious.
I’ve managed to create the GAME OVER text and display it:
def game_init():
...
global game_over_surface, game_over_pos
some_font = pygame.font.SysFont("arial", 64)
game_over_surface = some_font.render("GAME OVER", True, "white")
game_over_pos = game_over_surface.get_rect(centerx=u.CENTER.x, centery=u.CENTER.y)
def main_loop():
...
draw_everything()
if game_over: screen.blit(game_over_surface, game_over_pos)
Let’s extract the creation of the surface to a separate function. It turns out that PyGame can only render a single line of text to a surface, so we’ll need a bunch of them, and a new font because you only get one size per font. This is so retro, so very retro. I feel like I’m working on a late 1970s program. Oh, wait …
GAME OVER Screen
It was in fact tedious, but I have this somewhat working. As you’ll see, I wrote it out longhand, which was wasteful, but the best idea I had in the moment.
def game_over():
global game_over_surface, game_over_pos, help_lines
big_font = pygame.font.SysFont("arial", 64)
small_font = pygame.font.SysFont("arial", 48)
game_over_surface = big_font.render("GAME OVER", True, "white")
game_over_pos = game_over_surface.get_rect(centerx=u.CENTER.x, centery=u.CENTER.y)
pos_left = u.CENTER.x - 100
pos_top = u.CENTER.y + 100
help_lines = []
text = small_font.render("d - turn left", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
pos_top += 60
text = small_font.render("f - turn right", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
pos_top += 60
text = small_font.render("j - accelerate", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
pos_top += 60
text = small_font.render("k - fire missile", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
pos_top += 60
text = small_font.render("q - insert quarter", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
def draw_game_over():
screen.blit(game_over_surface, game_over_pos)
for text, pos in help_lines:
screen.blit(text, pos)
Here’s what we get:
Nearly good. The help text is probably too large, the initial spacing too far down. Quite close, when we take my general ineptness into account. Let’s first clean up the define
method.
How shall we do this? We have this repeated pattern:
text = small_font.render("d - turn left", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
help_lines.append((text, text_rect))
pos_top += 60
The only truly variable bit will be the text to be displayed. I’d like to do most of this with PyCharm refactorings, mostly for practice, partly because it makes fewer mistakes than I do.
Let’s first create the little pair that we are using:
text = small_font.render("d - turn left", True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
pair = (text, text_rect)
help_lines.append(pair)
That was Extract Variable, Command-Option-V. Now let’s inline text and text_rect for a moment.
No, can’t quite do that. Can it extract a method on the pair creation?
Bummer. I can’t see an automated sequence to get what I want. I’ll just have to program it, I guess. I can do this, I was made to do this.
def define_game_over():
global game_over_surface, game_over_pos, help_lines
big_font = pygame.font.SysFont("arial", 64)
small_font = pygame.font.SysFont("arial", 48)
game_over_surface = big_font.render("GAME OVER", True, "white")
game_over_pos = game_over_surface.get_rect(centerx=u.CENTER.x, centery=u.CENTER.y)
pos_left = u.CENTER.x - 100
pos_top = u.CENTER.y
help_lines = []
messages = ["d - turn left", "f - turn right", "j - accelerate", "k - fire missile", "q - insert quarter", ]
for message in messages:
pos_top += 60
text = small_font.render(message, True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
pair = (text, text_rect)
help_lines.append(pair)
I did that by collecting the messages via copy paste, then editing the first text/rect creation to refer to the message parameter. I moved the top increment up and changed pos_top, for better spacing. The result is in fact better:
I think if we moved the whole thing up to about 1/4 of the way down from screen top, we’d be satisfied with the look. Maybe move the x coordinate a bit more left?
I will settle for this:
def define_game_over():
global game_over_surface, game_over_pos, help_lines
big_font = pygame.font.SysFont("arial", 64)
small_font = pygame.font.SysFont("arial", 48)
game_over_surface = big_font.render("GAME OVER", True, "white")
game_over_pos = game_over_surface.get_rect(centerx=u.CENTER.x, centery=u.CENTER.y/2)
pos_left = u.CENTER.x - 150
pos_top = game_over_pos.centery
help_lines = []
messages = [
"d - turn left",
"f - turn right",
"j - accelerate",
"k - fire missile",
"q - insert quarter", ]
for message in messages:
pos_top += 60
text = small_font.render(message, True, "white")
text_rect = text.get_rect(topleft=(pos_left,pos_top))
pair = (text, text_rect)
help_lines.append(pair)
We’re spacing left 150 now, not 100, and starting at the center y of the GAME OVER text. Looks like this:
I’ll definitely settle for that, for now. I’ve changed the game_init
to initialize to zero, so the GAME OVER shows up right away. After a few seconds, the asteroids start to fly.
I’m not quite sure what inserting a quarter will have to do. I’d really like for it to be pretty simple, but there are quite a few things in game_init
that may have to be redone. Let’s review that function.
def game_init():
global screen, clock, running, delta_time, asteroids_in_this_wave
global wave_timer, ships, ships_remaining, game_over
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
asteroids_in_this_wave = 2
game_over = False
ships_remaining = u.SHIPS_PER_QUARTER
ships_remaining = 0
wave_timer = u.ASTEROID_TIMER_STOPPED
ships = []
set_ship_timer(u.SHIP_EMERGENCE_TIME)
running = True
delta_time = 0
define_game_over()
I’ll reorder this to put define_game_over up near the top, and all the things that need to be done to restart the game, after all those that do not. I think it’ll be like this:
def game_init():
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
define_game_over()
global asteroids, missiles, ships
asteroids = []
missiles = []
ships = []
global asteroids_in_this_wave, game_over, ships_remaining
asteroids_in_this_wave = 2
game_over = False
ships_remaining = u.SHIPS_PER_QUARTER
set_ship_timer(u.SHIP_EMERGENCE_TIME)
global wave_timer, delta_time
wave_timer = u.ASTEROID_TIMER_STOPPED
delta_time = 0
running = True
I think just the first bit is one-time only, and the rest is per game. Let’s extract all that and call it insert_quarter:
global running
global asteroids, missiles, ships
asteroids = []
missiles = []
ships = []
global asteroids_in_this_wave, game_over, ships_remaining
asteroids_in_this_wave = 2
game_over = False
ships_remaining = u.SHIPS_PER_QUARTER
set_ship_timer(u.SHIP_EMERGENCE_TIME)
global wave_timer, delta_time
wave_timer = u.ASTEROID_TIMER_STOPPED
delta_time = 0
running = True
Now I’d like a parameter on this method, the number of ships remaining, because I want to start the game in the GAME_OVER state. So:
def game_init():
global screen, clock, running
pygame.init()
screen = pygame.display.set_mode((u.SCREEN_SIZE, u.SCREEN_SIZE))
pygame.display.set_caption("Asteroids")
clock = pygame.time.Clock()
define_game_over()
insert_quarter(0)
def insert_quarter(number_of_ships):
global running
global asteroids, missiles, ships
asteroids = []
missiles = []
ships = []
global asteroids_in_this_wave, game_over, ships_remaining
asteroids_in_this_wave = 2
game_over = False
ships_remaining = number_of_ships
set_ship_timer(u.SHIP_EMERGENCE_TIME)
global wave_timer, delta_time
wave_timer = u.ASTEROID_TIMER_STOPPED
delta_time = 0
running = True
And when you type q …
def control_ship(ship, dt):
keys = pygame.key.get_pressed()
if keys[pygame.K_q]:
insert_quarter(u.SHIPS_PER_QUARTER)
if keys[pygame.K_f]:
ship.turn_left(dt)
if keys[pygame.K_d]:
ship.turn_right(dt)
if keys[pygame.K_j]:
ship.power_on(dt)
else:
ship.power_off()
if keys[pygame.K_k]:
ship.fire_if_possible(missiles)
else:
ship.not_firing()
I expect this to work, possibly with some things set to the wrong values. Like the wave size, did I set that to 2? Yes. Test in game.
I get an exception, not quite sure what I did.
File "/Users/ron/PycharmProjects/firstGo/asteroid.py", line 40, in collide_with_attacker
self.split_or_die(asteroids)
File "/Users/ron/PycharmProjects/firstGo/asteroid.py", line 43, in split_or_die
asteroids.remove(self)
ValueError: list.remove(x): x not in list
Well, we know that Python, in its wisdom, does not have a safe remove, but where did we find an asteroid that isn’t in the list? Or did we perhaps hit it twice somehow? Let’s see how easy it is to reproduce this error.
(Ron plays game a while …)
I cannot reproduce it easily. Let’s review the collision code … but first another interesting bug. I’ve been stopping the game by typing command q, and now that doesn’t work any more. I’m not sure why it ever worked. The relevant code is:
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
Ah, this makes me chuckle. We’re setting running
to True
in insert_quarter
. We need not do that, and should not. We’ll move that line up to the init.
OK the bug. I think that the crash must have come when two things tried to destroy the same asteroid, so it got split / removed twice. An easy fix will be to build a safe delete into all those situations, but let’s see if we can find the issue in the code.
This attracts my eye:
def check_asteroids_vs_missiles():
for asteroid in asteroids.copy():
for missile in missiles.copy():
asteroid.collide_with_attacker(missile, missiles, asteroids)
Suppose that two missiles somehow enter kill range on the same asteroid at the same cycle. We’re looping over asteroids.copy() so we’ll test the same asteroid against all the missiles. If two of them ever do hit it at the same time, the asteroid will still exist on the second time around, and will try to split both times:
def collide_with_attacker(self, attacker, attackers, asteroids):
if self.withinRange(attacker.position, attacker.radius):
if attacker in attackers: attackers.remove(attacker)
self.split_or_die(asteroids)
def split_or_die(self, asteroids):
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
I notice that I’ve protected the attacker against double deletes. I think I want to do two checks. No, this one will do it all:
def split_or_die(self, asteroids):
if self not in asteroids: return # already dead
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
If we attempt to split_or_die
a dead asteroid, nothing will happen. I think that fixes the defect.
My disciplined side tells me that I should have a test for this. Let me commit all this perfectly good code and think about that.
Commit: game starts in game over mode. q inserts quarter to begin game. fixed issue with simultaneous kill of same asteroid.
That was another sin, wasn’t it? I “should” have committed the fix separately. I forgive myself, my team allows that sort of thing because we have never had to roll back a commit in the entire history of this Python project. (That’s a “hold my beer” if I ever wrote one.)
OK, what about a test for that bug? It’s just one method needing testing:
def split_or_die(self, asteroids):
if self not in asteroids: return # already dead
asteroids.remove(self)
if self.size > 0:
a1 = Asteroid(self.size - 1, self.position)
asteroids.append(a1)
a2 = Asteroid(self.size - 1, self.position)
asteroids.append(a2)
Heck yes, we can test that.
# it's barely possible for two missiles to kill the
# same asteroid. This used to cause a crash, trying
# to remove the same asteroid twice.
def test_dual_kills(self):
asteroid = Asteroid(2, Vector2(0, 0))
asteroids = [asteroid]
asteroid.split_or_die(asteroids)
assert asteroid not in asteroids
assert len(asteroids) == 2
asteroid.split_or_die(asteroids)
assert len(asteroids) == 2 # didn't crash, didn't split again
I rather like that. It’s green. Commit: added test for attempt to kill same asteroid twice.
OK, the code of the morning is sufficient thereto. We’ll sum up.
Summary
Getting the help added to GAME OVER was straightforward. A more clever man might have written the loop right out of the box, but since this was the second time I even did text in PyGame, the copy-paste four times approach worked better for me. I’m disappointed that I couldn’t think of a clean way of refactoring to the loop but them’s the breaks. It was easy enough.
I would argue that as a pure GUI function, there was no need to test the help other than by looking at it. As for insert_quarter
, it did have a bug in it, the Running=True
, but as the program is configured, there’s not much to test. I suppose we could go through and test “did he set the asteroids empty” and so on but really, why would we do that?
The interesting crash was interesting. It seems clear that one thing that could happen is two missiles hitting the same asteroid. It does seem very unlikely. They’d have to be flying in close formation and the asteroid would have to hit them from the side. But I think we know that something hit a dead asteroid, and since we’re iterating a copy of the list, that’s possible only if two missiles hit it. The fix was just to ignore an attempt to kill an asteroid that isn’t in the master list.
And we even wrote a little test to check that. And, by the way, if I remove that check, the test does fail, so there!
The help text was a bit tedious but not terribly so, and the code for the help is decent if not great. I’m calling it a morning, and morning it shall be.
See you next time!