Python Asteroids on GitHub

It’s afternoon and I have some distractions. I’d be wise not to try anything, but maybe I can find some tiny safe steps.

The thing is to move ship spawning down, into ShipFleet. Here’s the relevant code as it stands now.

    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.control_game(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

    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.tick(delta_time, ship, ships)

    def set_ship_timer(self, seconds):
        self.ship_timer = Timer(seconds, self.spawn_ship_when_ready)

    def spawn_ship_when_ready(self, ship, ships):
        if not self.safe_to_emerge(self.missiles, self.asteroids):
            return False
        ship.reset()
        ships.append(ship)
        self.ships_remaining -= 1
        return True

    def safe_to_emerge(self, missiles, asteroids):
        if missiles: return False
        for asteroid in asteroids:
            if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
                return False
        return True

Quite a lot to deal with there. What threads can we tease out? I already have a ShipFleet, what does it do now? It does nothing, it’s left over from a prior attempt at this. What if I just TDD in all of this, inch by inch, and then, when it’s working, plug it in?

I’d like to think I’ll get it all done this afternoon, but that seems unlikely. I think I need a feature flag, so that my new code only runs under test.

I’ll put in a class variable:

class ShipFleet(Fleet):
    rez_from_fleet = False
    
    def __init__(self, flyers):
        super().__init__(flyers)

Now I can do everything behind that curtain. Let’s write a test.

Aside:
Well, I could if I actually checked the flag in ShipFleet, but I forgot for quite a while, resulting in some commits that probably didn’t work as a game.
    def test_ship_rez(self):
        ShipFleet.rez_from_fleet = True

I’d better put a False setting into Game while I think about it. In fact, I put it right in the game startup:

if __name__ == "__main__":
    keep_going = True
    ShipFleet.rez_from_fleet = False
    while keep_going:
        asteroids_game = Game()
        keep_going = asteroids_game.main_loop()

We should be safe now. Let’s test.

    def test_ship_rez(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        assert not ships

So far so good … carry on:

    def test_ship_rez(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(0.1, fleets)
        assert not ships

Still green. This is really easy so far. Not much worth committing but let’s get in the habit: Feature flag and initial testing beginning on ship rezzing in ShipFleet.

    def test_ship_rez(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(0.1, fleets)
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships

This fails. At last, something to do! I’ll add a timer like the one in Game.

class ShipFleet(Fleet):
    rez_from_fleet = False

    def __init__(self, flyers):
        super().__init__(flyers)
        self.ship_timer = Timer(u.SHIP_EMERGENCE_TIME, self.spawn_ship_when_ready)

    def spawn_ship_when_ready(self, fleets):
        ships = fleets.ships
        ships.append(Ship(u.CENTER))

    def tick(self, delta_time, fleets):
        ships = fleets.ships
        if len(ships) == 0:
            self.ship_timer.tick(delta_time, fleets)
        super().tick(delta_time, fleets)
        return True

The test is running green. I am actually a bit surprised.

A different test failed until I put in the return True, this one:

    def test_fleets_tick(self):
        asteroids = [FakeFlyer()]
        missiles = [FakeFlyer()]
        saucers = [FakeFlyer()]
        saucer_missiles = [FakeFlyer()]
        ships = [FakeFlyer()]
        fleets = Fleets(asteroids, missiles, saucers, saucer_missiles, ships)
        result = fleets.tick(0.1)
        assert result

I think that’s just making sure that everyone is returning True. That whole True/False thing needs revision or, probably, removing.

OK, this test is legitimate because there’s nothing around to make it unsafe. So let’s add another one.

    def test_unsafe_because_asteroid(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        asteroid = Asteroid()
        asteroid.position = u.CENTER
        asteroids = [asteroid]
        fleets = Fleets(asteroids, [], [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships

This should fail on the last line, and that’s what it does. We code … I wish I had done the test for missiles. Let’s change the test before it’s too late.

    def test_unsafe_because_missile(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        missile = Missile(u.CENTER, Vector2(0,0), [0,0,0], [0,0,0])
        missiles = [missile]
        fleets = Fleets([], missiles, [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships

Then code:

    def spawn_ship_when_ready(self, fleets):
        if self.safe_to_emerge(fleets):
            ships = fleets.ships
            ships.append(Ship(u.CENTER))
            return True
        else:
            return False

    def safe_to_emerge(self, fleets):
        missiles = fleets.missiles
        if len(missiles) > 0:
            return False
        return True

I coded a bit past my test there, but I can now enbhance the test.

    def test_unsafe_because_missile(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        missile = Missile(u.CENTER, Vector2(0,0), [0,0,0], [0,0,0])
        missiles = [missile]
        fleets = Fleets([], missiles, [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships
        missiles.clear()
        ship_fleet.tick(0.001, fleets)
        assert ships

Test passes. Duplicate it except with saucer missiles:

    def test_unsafe_because_saucer_missile(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        missile = Missile(u.CENTER, Vector2(0,0), [0,0,0], [0,0,0])
        saucer_missiles = [missile]
        fleets = Fleets([], [], saucer_missiles, [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships
        saucer_missiles.clear()
        ship_fleet.tick(0.001, fleets)
        assert ships

That’s failing but this should fix it:

    def safe_to_emerge(self, fleets):
        if len(fleets.missiles) > 0:
            return False
        if len(fleets.saucer_missiles) > 0:
            return False
        return True

I am surprised that the test still fails. It rezzed a ship at the emergence time, suggesting that safe_to_emerge was True. That’s because I put the saucer_missiles at the wrong position. This works:

        fleets = Fleets([], [], [], saucer_missiles, ships)

We are green. I should be committing all along here. Commit: ship rezzing in ShipFleet now detects unsafe if missiles or saucer missiles are present.

We’re down to the harder one, detecting asteroids that are too close. Let’s review how the game does that.

Look! An undiscovered bug:

    def spawn_ship_when_ready(self, ship, ships):
        if not self.safe_to_emerge(self.missiles, self.asteroids):
            return False
        ship.reset()
        ships.append(ship)
        self.ships_remaining -= 1
        return True

This code doesn’t check for saucer missiles in flight. When I did the saucer missiles I must have forgotten that. Doing an explicit test this time kept me from making the same mistake. Anyway, how does safe_to_emerge deal with the asteroids?

    def safe_to_emerge(self, missiles, asteroids):
        if missiles: return False
        for asteroid in asteroids:
            if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
                return False
        return True

We’ll replicate that in spirit, but also in the spirit of our current scheme. Oh, darn, I forgot to write the test. Here goes:

    def test_unsafe_because_asteroid(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        asteroid = Asteroid()
        asteroid.position = u.CENTER + Vector2(u.SAFE_EMERGENCE_DISTANCE - 0.1, 0)
        asteroids = [asteroid]
        fleets = Fleets(asteroids, [], [], [], ships)
        ship_fleet = fleets.ships
        assert not ships
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships
        asteroids.clear()
        ship_fleet.tick(0.001, fleets)
        assert ships

Same pattern, put something in that should prohibit rezzing, test that the doesn’t rez, remove it, test that the ship rezzes. The code is now:

class ShipFleet(Fleet):
    rez_from_fleet = False

    def __init__(self, flyers):
        super().__init__(flyers)
        self.ship_timer = Timer(u.SHIP_EMERGENCE_TIME, self.spawn_ship_when_ready)

    def spawn_ship_when_ready(self, fleets):
        if self.safe_to_emerge(fleets):
            ships = fleets.ships
            ships.append(Ship(u.CENTER))
            return True
        else:
            return False

    def safe_to_emerge(self, fleets):
        if len(fleets.missiles) > 0:
            return False
        if len(fleets.saucer_missiles) > 0:
            return False
        return self.asteroids_far_enough_away(fleets.asteroids)

    def asteroids_far_enough_away(self, asteroids):
        for asteroid in asteroids:
            if asteroid.position.distance_to(u.CENTER) < u.SAFE_EMERGENCE_DISTANCE:
                return False
        return True

    def tick(self, delta_time, fleets):
        ships = fleets.ships
        if len(ships) == 0:
            self.ship_timer.tick(delta_time, fleets)
        super().tick(delta_time, fleets)
        return True

The test is green. Commit: ShipFleet fully tested to handle ship rezzing.

I should be able to turn off the feature flag and O M G!!!

I haven’t been testing the flag in the code at all. All my releases this afternoon have had live ship rezzing in ShipFleet. What a tiny fool!

Tick should have looked like this:

    def tick(self, delta_time, fleets):
        ships = fleets.ships
        if self.rez_from_fleet and len(ships) == 0:
            self.ship_timer.tick(delta_time, fleets)
        super().tick(delta_time, fleets)
        return True

I’ll quickly commit to fix that. Commit: oops, feature flag wasn’t checked.

No excuse, sir, but that was probably the first time in years that I ever tried to use a feature flag.

Now let’s turn off ship rezzing in the game and test the new code in play.

No surprise at all, it works perfectly. Except that you get all the ships you want. I have no test for that.

Two mistakes. And I felt I was doing so well.

OK, suck it up buttercup, write the test and make it go.

    def test_can_run_out_of_ships(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        ship_fleet.ships_remaining = 2
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 1
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 0
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships

I’ve decided that ShipFleet will keep the ships_remaining count. This test is failing. It should be on the first assertion about ships_remaining. Yes. OK this should be easy so far.

class ShipFleet(Fleet):
    rez_from_fleet = False

    def __init__(self, flyers):
        super().__init__(flyers)
        self.ship_timer = Timer(u.SHIP_EMERGENCE_TIME, self.spawn_ship_when_ready)
        self.ships_remaining = u.SHIPS_PER_QUARTER

    def spawn_ship_when_ready(self, fleets):
        if not self.ships_remaining:
            return True
        if self.safe_to_emerge(fleets):
            ships = fleets.ships
            ships.append(Ship(u.CENTER))
            self.ships_remaining -= 1
            return True
        else:
            return False

But the big problem is getting back to GAME OVER. In Game, it works like this:

class Game:
    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.tick(delta_time, ship, ships)

How can we signal game over? I think what will happen now is that the game will rez your four ships but then when the last one goes down, it’ll just keep cycling. Of course you can still type q, you just won’t get the game over signal.

Hmm, that gives me an idea. First I want to verify that it just cycles.

That is what happens … and also, no surprise but I wasn’t thinking about it, the array of ships under the score doesn’t count down.

This is in game:

    def draw_available_ships(self):
        ship = Ship(Vector2(20, 100))
        ship.angle = 90
        for i in range(0, self.ships_remaining):
            self.draw_available_ship(ship)

I do this:

    def draw_available_ships(self):
        ship = Ship(Vector2(20, 100))
        ship.angle = 90
        ships_remaining = ShipFleet.ships_remaining if ShipFleet.rez_from_fleet else self.ships_remaining
        for i in range(0, ships_remaining):
            self.draw_available_ship(ship)

Of course when we’re all done here, I’ll just ask the fleet, but for now I want to honor the feature flag (finally).

That suggests that we could have ShipFleet class know about game over as well.

Right now that’s done in Game’s asteroid_tick:

    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        if not ShipFleet.rez_from_fleet:
            self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.control_game(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

Let’s add that class variable as well. We can test that:

    def test_can_run_out_of_ships(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        ship_fleet.ships_remaining = 2
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 1
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 0
        assert not ship_fleet.game_over
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships
        assert ship_fleet.game_over
    def spawn_ship_when_ready(self, fleets):
        if not self.ships_remaining:
            ShipFleet.game_over = True
            return True
        if self.safe_to_emerge(fleets):
            ships = fleets.ships
            ships.append(Ship(u.CENTER))
            ShipFleet.ships_remaining -= 1
            return True
        else:
            return False

I have to tweak the test. Can’t store into the class variable using an instance: Python will create a member variable. So:

    def test_can_run_out_of_ships(self):
        ShipFleet.rez_from_fleet = True
        ships = []
        fleets = Fleets([], [], [], [], ships)
        ship_fleet = fleets.ships
        ShipFleet.ships_remaining = 2
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 1
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert ships
        assert ship_fleet.ships_remaining == 0
        assert not ship_fleet.game_over
        ships.clear()
        ship_fleet.tick(u.SHIP_EMERGENCE_TIME, fleets)
        assert not ships
        assert ship_fleet.game_over

The test runs.

Change Game:

    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        if not ShipFleet.rez_from_fleet:
            self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.control_game(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        game_over = ShipFleet.game_over if ShipFleet.rez_from_fleet else self.game_over
        if game_over: self.draw_game_over()

Now if I change main, I think I should get GAME OVER as intended.

if __name__ == "__main__":
    keep_going = True
    ShipFleet.rez_from_fleet = True
    while keep_going:
        asteroids_game = Game()
        keep_going = asteroids_game.main_loop()

game over

I think we’re good. This new version is better than the old one, I believe. We could make it even better by checking for saucer on the screen, but the saucer_missile check is probably strong enough.

Let’s commit with the flag set to use ShipFleet. Commit: new ShipFleet ship rezzing logic is now in play.

And now let’s remove the flag and all the code formerly under the flag.

I’ve messed something up. I’ll roll back and take a break, I’ve been at this too long and I’m tired.

Summary

It has gone quite nicely. Test-driving the new code into ShipFleet has worked well, and we’re running on the new code in the game. Somehow in unwinding the feature flag, I broke something. I’ll have to do that a little more carefully next time.

Aside from some unused code, we’re done, I think, with moving the asteroids-specific code out of Game.

I’m a bit leery of using ShipFleet’s class member as the game over flag. We may need a better way to do that. It could be as simple as a return from tick.

Except Oops

I put in my feature flag and then forgot to use it in the new implementation. The result of that was that there were surely at least a few commits that would not correctly run the game. I blame Chet. And perhaps the fact that I’ve not done a feature flag … perhaps ever.

Other than that, it has gone rather well and without much confusion at all. I fancy the earlier moves would likely have gone better with direct test-driving instead of trying to rely on the existing tests. Live and learn … again and again and again.

Or, maybe my brain has improved since yesterday. It could happen.

See you next time!