Python Asteroids on GitHub

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:

saucer in top left

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.

starting from right

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!