Python 041 - Moar Saucer
Continuing the saucer implementation in a second contemporaneous article, just to spare your eyes.
From last time:
Looking forward … I think we’ll follow the ship’s pattern, where the Game owns the ship and has a ships collection which may or may not currently contain a ship. When it doesn’t, and a suitable time has elapsed, the game will initialize the Saucer and pop it into the collection, and voila! it will do its thing. I’ll do some of this as I plan. And we’ll want some tests.
I just coded right through, the article is just split to keep your eye-strain down.
class Game:
def __init__ ...
...
self.saucer = Saucer(Vector2(u.SCREEN_SIZE/4, u.SCREEN_SIZE/4))
self.saucers = []
...
We’ll want a saucer_timer. Add it. And a saucer_zigzag_timer, add that also.
self.saucer = Saucer(Vector2(u.SCREEN_SIZE/4, u.SCREEN_SIZE/4))
self.saucers = []
self.saucer_timer = 0
self.saucer_zigzag_timer = 0
Let’s review what the game loop does about the ship.
...
self.check_ship_spawn(self.ship, self.ships, self.delta_time)
...
def check_ship_spawn(self, ship, ships, delta_time):
if ships: return
if self.ships_remaining <= 0:
self.game_over = True
return
self.ship_timer -= delta_time
if self.ship_timer <= 0 and self.safe_to_emerge(self.missiles, self.asteroids):
ship.reset()
ships.append(ship)
self.ships_remaining -= 1
We have tests for check_ship_spawn. Let’s write some similar ones for the saucer. I know I could just type it in, I’m a programmer after all, but let’s be realistic, it’ll go better with tests telling me when I’ve got it right.
def test_spawn_saucer(self):
game = Game(testing=True)
saucer = game.saucer
game.game_init()
game.check_saucer_spawn(saucer, game.saucers, 0.1)
assert not game.saucers
game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
assert saucer in game.saucers
I need the method and the u
constant. I set the constant to 7. Now the method.
def check_saucer_spawn(self, saucer, saucers, delta_time):
if saucers: return
self.saucer_timer -= delta_time
if self.saucer_timer <= 0:
saucer.ready()
saucers.append(saucer)
Saucer doesn’t understand ready yet. Give it an empty one. The test passes. Let’s put the check into the game. And change draw:
screen = self.screen
screen.fill("midnightblue")
for saucer in self.saucers:
saucer.draw(screen)
for ship in self.ships:
ship.draw(screen)
for asteroid in self.asteroids:
asteroid.draw(screen)
for missile in self.missiles:
missile.draw(screen)
self.draw_score()
self.draw_available_ships()
That should draw the saucer at the top quarter point of the screen, seven seconds after I insert a quarter. And it does:
Now the saucer isn’t supposed to stay on screen forever, and it shouldn’t just sit in one place. It moves at some speed, and it only says alive for about one screen width. I’ve always done that with a timer in the past. I was thinking about making it fly until it hits the far edge. How might we do that?
If it’s flying left to right, it should disappear when x > u.SCREEN_SIZE. When right to left, when x < 0. And, in fact we don’t care which way it’s going, we can just always check both limits.
Let’s work on the saucer’s motion. We’ll try some tests.
class TestSaucer:
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.direction == 1
assert saucer.velocity == u.SAUCER_VELOCITY
saucer.ready()
assert saucer.position.x == 0
assert saucer.direction == -1
assert saucer.velocity == u.SAUCER_VELOCITY
This demands a bit of work. Along the way I change the test:
class TestSaucer:
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == u.SAUCER_VELOCITY
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == -u.SAUCER_VELOCITY
I don’t want to test the direction flag, I want to test the direction. ready
, so far, looks like this:
def ready(self):
self.velocity = self.direction*u.SAUCER_VELOCITY
self.direction = -self.direction
self.position = Vector2(0, 555)
The test runs. We want the y coordinate to be random between 0 and screen size. We can borrow that from Asteroid.
def ready(self):
self.velocity = self.direction*u.SAUCER_VELOCITY
self.direction = -self.direction
self.position = Vector2(0, random.randrange(0, u.SCREEN_SIZE))
Now we need to move the saucer. Shall we write a test for that? Sure, why not.
def test_move(self):
saucer = Saucer()
saucer.ready()
starting = saucer.position
saucer.move(1)
assert saucer.position.x == u.SAUCER_VELOCITY.x
In one second, it should move a distance equal to its velocity. Code move
:
def move(self, delta_time):
self.position = delta_time*self.velocity
The test is passing. Let’s make it move across the screen.
for the_saucer in self.saucers:
the_saucer.move(dt)
for the_ship in self.ships:
the_ship.move(dt)
for asteroid in self.asteroids:
asteroid.move(dt)
for missile in self.missiles:
missile.move(dt)
With that in place, the saucer will start after seven seconds, fly straight across the screen and then fly off screen. Except that it doesn’t. It appears in the top left corner and stays there. Curious.
Oh. I meant += not =
def move(self, delta_time):
self.position += delta_time*self.velocity
Test wasn’t strong enough. Fix it.
def test_move(self):
saucer = Saucer()
saucer.ready()
starting = saucer.position
saucer.move(1)
assert saucer.position.x == u.SAUCER_VELOCITY.x
saucer.move(1)
assert saucer.position.x == 2*u.SAUCER_VELOCITY.x
Now we should fly. Works as advertised. Now to test vanishing at the edge.
def test_vanish_at_edge(self):
saucer = Saucer()
saucers = [saucer]
saucer.ready()
saucer.move(1, saucers)
assert saucers
saucer.move(u.SCREEN_SIZE/u.SAUCER_VELOCITY.x, saucers)
assert not saucers
My thinking here is that we’ll pass in the saucers collection and we can remove the saucer if it has crossed the edge. The move amount in the second move should be the number of seconds to cross the screen, which should give us a position > screen size. Let me add that assertion.
def test_vanish_at_edge(self):
saucer = Saucer()
saucers = [saucer]
saucer.ready()
saucer.move(1, saucers)
assert saucers
saucer.move(u.SCREEN_SIZE/u.SAUCER_VELOCITY.x, saucers)
assert saucer.position.x > u.SCREEN_SIZE
assert not saucers
Of course move doesn’t take a second parameter yet.
def move(self, delta_time, saucers):
self.position += delta_time*self.velocity
x = self.position.x
if x < 0 or x > u.SCREEN_SIZE:
if self in saucers:
saucers.remove(self)
And the test isn’t passing. Why not? Oh it’s the other sender, not passing a collection. We’ll have the same problem in the game of course. Fix the test:
def test_move(self):
saucer = Saucer()
saucer.ready()
starting = saucer.position
saucer.move(1, [])
assert saucer.position.x == u.SAUCER_VELOCITY.x
saucer.move(1, [])
assert saucer.position.x == 2*u.SAUCER_VELOCITY.x
And fix the game:
def move_everything(self,dt):
for the_saucer in self.saucers.copy():
the_saucer.move(dt, self.saucers)
...
The tests are green. Test in game. I expect a problem with the timer not being reset. I’ll sort that in a moment.
Suddenly I realize that a test in game will tell me nothing, because I can’t tell the difference between the saucer vanishing at the edge and flying on forever. I have to test the timer reset. Should be easy enough:
def test_spawn_saucer(self):
game = Game(testing=True)
saucer = game.saucer
game.game_init()
game.check_saucer_spawn(saucer, game.saucers, 0.1)
assert not game.saucers
game.check_saucer_spawn(saucer, game.saucers, u.SAUCER_EMERGENCE_TIME)
assert saucer in game.saucers
assert game.saucer_timer == u.SAUCER_EMERGENCE_TIME
Test starts failing immediately. Fix it:
def check_saucer_spawn(self, saucer, saucers, delta_time):
if saucers: return
self.saucer_timer -= delta_time
if self.saucer_timer <= 0:
saucer.ready()
saucers.append(saucer)
self.saucer_timer = u.SAUCER_EMERGENCE_TIME
Tests go green. I really like the auto-run thing. Saves me clicks. :)
Now the saucer should spawn on the left, fly to the right then later spawn and fly left.
It’s not working as advertised. The saucer does spawn at 7 seconds and fly across. But seven seconds later, I get a spawn message that I printed, but no saucer. Then what seems like just a few seconds later I get a left-to-right one again.
I paste in a print and get this output:
saucer spawn at 9.327000000000018
saucer spawn at 16.328999999999834
saucer spawn at 23.330999999999566
The middle one, a saucer doesn’t appear. The times look good though. Is there something wrong with move
or ready
? This article is getting too long.
def move(self, delta_time, saucers):
self.position += delta_time*self.velocity
x = self.position.x
if x < 0 or x > u.SCREEN_SIZE:
if self in saucers:
saucers.remove(self)
def ready(self):
self.velocity = self.direction*u.SAUCER_VELOCITY
self.direction = -self.direction
self.position = Vector2(0, random.randrange(0, u.SCREEN_SIZE))
Sure looks good to me. Let me see if I can write a test that’ll find this.
The test finds the bug before I can even run it:
def test_right_to_left(self):
saucer = Saucer()
saucers = [saucer]
saucer.move(1, saucers)
assert saucers
saucer.move(u.SCREEN_SIZE/u.SAUCER_VELOCITY.x, saucers)
assert saucer.position.x > u.SCREEN_SIZE
assert not saucers
saucer.ready()
assert saucer.x == 0
assert saucer.velocity.x < 0 # ohhh
You can see where the light bulb went on. “ohhh”
When we start the saucer we always start it at x = 0. If it’s moving right to left it’ll immediately move to -something
and vanish. We need to ready it at u.SCREEN_SIZE if we’re going to move right to left. Put that in the test.
def test_right_to_left(self):
saucer = Saucer()
saucers = [saucer]
saucer.move(1, saucers)
assert saucers
saucer.move(u.SCREEN_SIZE/u.SAUCER_VELOCITY.x, saucers)
assert saucer.position.x > u.SCREEN_SIZE
assert not saucers
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
Now in ready
:
def ready(self):
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.direction = -self.direction
And I improve the test:
def test_right_to_left(self):
saucer = Saucer()
saucers = [saucer]
saucer.ready()
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
And I fix up a previous test, which may have sufficed to cover this one.
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == u.SAUCER_VELOCITY
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
assert saucer.velocity == -u.SAUCER_VELOCITY
I’ll keep the new one, it’s more direct. Now I expect the saucer to spawn every seven seconds and go the other way. I’ll try to stay alive that long.
I am surprised to find the saucer starting from the right. Otherwise everything is OK. I think I need to set direction in game_init.
def game_init(self):
self.running = True
self.saucer.direction = 1
self.insert_quarter(0)
That fixes the issue and this article is way too long. Let’s commit, reflect and sum up. Commit: saucer flies every seven seconds. straight flight, invulnerable, maybe starts too close to top/bottom.
Reflective Summary
This went pretty nicely for as tricky as the saucer is. I’m glad I started writing those tests. I don’t think it would have gone as quickly, and I’m sure I’d have made mistakes that the tests caught.
I very much like PyCharm’s auto-test feature. If I pause my typing for a couple of seconds, it runs the tests and a little flag pops up green or red. So as soon as I get it right, I get that tasty feedback. And while it’s wrong, or when I break something, that slightly bitter-tasting feedback. It saves me lots of clicking, and gives me information I wouldn’t otherwise get, because I wouldn’t run the tests every time I stop to think. Very nice.
I don’t quite like setting the Saucer’s direction in game_init
. We should send a message to the saucer. Let’s fix that now, it’s as easy as writing a sticky note.
class Game
def game_init(self):
self.running = True
self.saucer.init_for_new_game()
self.insert_quarter(0)
class Saucer
def init_for_new_game(self):
self.direction = 1
I am tempted to randomize that so that the Saucer starts randomly left or right. For now, that’s what we intend: always start left to right.
There’s plenty more to do on Saucer. Now let’s split this article for readability and get on to reading whatever Victorian Fantasy Romance I’m consuming.
This went well, and I’m not really even tired after more than four hours at it.
See you next time!