Python Asteroids on GitHub

Are there still easy ways to make use of our Fleets and Fleet classes? The remaining cases may be a bit more difficult. “Repeat, not quite the same way.”

Let’s see what we have to deal with. There’s new waves of asteroids, leading to game over:

class Game:
    def check_next_wave(self, delta_time):
        if not self.asteroids:
            self.wave_timer.tick(delta_time, self.asteroids)

Is there a better place to do this? If and when we separate into the raw game logic and the asteroids game logic, this could be in the asteroids part, but where else could this reside?

Suppose there was a special kind of Fleet, an AsteroidFleet. And suppose that when the timer ticks, we would tick each Fleet. The vanilla fleets might do nothing, but the AsteroidFleet could do the wave checking logic, refilling itself as it needs to. That might almost be elegant.

We could also have a ShipFleet, which could renew the ship as needed. That might be more difficult because we’d have to deal with

  • knowing if there are ships left to use;
  • signaling game over if not
  • knowing if it’s safe to emerge, which requires
  • knowing if there are still missiles in flight;
  • knowing if there is still a saucer on screen;
  • knowing if there are asteroids too close to home.

Those are questions that the Fleets object could answer, but the individual ShipFleet could not.

This sounds interesting. Let’s move in that direction. We need a rough plan:

Rough Plan

  1. Send Fleets a tick message;
  2. Fleets might use that to do move and draw, to be determined;
  3. From Fleets, send tick to all Fleet instances;
  4. Vanilla Fleet, ignores tick.

Let’s do it. The tick starts here:

    def asteroids_tick(self, delta_time):
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        self.check_next_wave(delta_time)
        self.control_ship(self.ship, delta_time)
        self.move_everything(delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

Let’s TDD tick at least somewhat.

    def test_fleets_tick(self):
        asteroids = ["asteroid"]
        missiles = ["missile"]
        saucers = ["saucer"]
        saucer_missiles = ["saucer_missile"]
        ships = ["ship"]
        fleets = Fleets(asteroids, missiles, saucers, saucer_missiles, ships)
        result = fleets.tick(0.1)
        assert result

I’ve just randomly decided that tick will return True or False. Why? Speculation, but I think we can use it to communicate back to the Game that something interesting happened, like maybe GAME OVER. Purely speculative, but I wanted to put it in now.

class Fleets:
    def tick(self, delta_time):
        result = True
        for fleet in self.fleets:
            result = result and fleet.tick(delta_time)
        return result

This now demands that Fleet listen up.

class Fleet:
    def tick(self, delta_time):
        return True

To no one’s surprise, my new test is green. Let’s commit: Fleets and Fleet implement tick, with Fleets returning True if every Fleet returns True.

Now let’s have the Game send tick to the Fleets object. I rename this method and modify it so that the member space_objects is now named fleets:

    # noinspection PyAttributeOutsideInit
    def init_fleets(self):
        asteroids = []
        missiles = []
        self.saucer = Saucer(Vector2(u.SCREEN_SIZE / 4, u.SCREEN_SIZE / 4))
        saucers = []
        saucer_missiles = []
        self.ship = Ship(pygame.Vector2(u.SCREEN_SIZE / 2, u.SCREEN_SIZE / 2))
        ships = []
        self.fleets = Fleets(asteroids, missiles, saucers, saucer_missiles, ships)

And in the main asteroids tick:

    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        self.check_next_wave(delta_time)
        self.control_ship(self.ship, delta_time)
        self.move_everything(delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

Let’s reflect.

Reflection

I am starting to see, vaguely, how we might be able to push all these operations in asteroids_tick down into Fleets, and often down into a single Fleet, often without needing to say anything explicit, just putting more and more intelligence into a given Fleet object’s tick operation.

It seems likely that we can make use of the True return there to set game_over, but we’ll hold that thought for a moment.

Commit this: Game sends tick to Fleets from asteroids_tick.

Looking at the things that are done in asteroids_tick, I’d like to pick the simplest thing. I think that’s move. Let’s remove the call to move_everything from here and put it in Fleets.tick.

class Fleets:
    def tick(self, delta_time):
        result = True
        for fleet in self.fleets:
            result = result and fleet.tick(delta_time)
        self.move_everything(delta_time)
        return result

One test fails. My use fake strings in the Fleet instances bites me:

    def test_fleets_tick(self):
        asteroids = ["asteroid"]
        missiles = ["missile"]
        saucers = ["saucer"]
        saucer_missiles = ["saucer_missile"]
        ships = ["ship"]
        fleets = Fleets(asteroids, missiles, saucers, saucer_missiles, ships)
        result = fleets.tick(0.1)
        assert result

This change is sending move to those strings. I’ll make a FakeFlyer object.

class FakeFlyer:
    def __init__(self):
        pass

    def move(self, delta_time, fleet):
        pass

And use it:

    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

Tests are green. Game is good. Commit: Game no longer does move_everything, done in Fleets.tick.

Reflection

I think that’s kind of significant. The game doesn’t know any more that things need to be moved. All it knows about that is that Fleets wants to be ticked. Fleets knows about moving. I wonder what else we can push down there.

class Game:
    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        self.check_next_wave(delta_time)
        self.control_ship(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

Let’s do the ship’s controls. That should be an easy one. We want a new kind of Fleet, a ShipFleet. I think I’ll put the subclasses right in the Fleet module.

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

    def move(self, delta_time):
        for flyer in self:
            flyer.controls()
            flyer.move(delta_time, self)

We know that tick calls move now, so we will just check controls before we move. If there is no ship, the loop won’t run.

Ah. There’s a bit more in here than I was aware:

    def control_ship(self, ship, dt):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q]:
            self.keep_going = True
            self.running = False
        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(self.missiles)
        else:
            ship.not_firing()

We need to deal with the keep_going and running flags. I’ll leave those to Game and do the actual ship controls.

No. This is no good. I need access to missiles and other things. Roll back everything clear back to move and look again.

How did we do this with the Saucer? Ah, we broke it out:

    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        self.check_next_wave(delta_time)
        self.control_ship(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

    def check_saucer_firing(self, delta_time, saucers, saucer_missiles, ships):
        for saucer in saucers:
            saucer.fire_if_possible(delta_time, saucer_missiles, ships)

We could do that, and then we’d know to send the ship its missiles and send the saucer its missiles.

Another issue is the keys. Ah, I just didn’t see where to get those. Let’s try again.

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

I think I’ll commit that as a save point. It’s mostly harmless. Commit: empty ShipFleet class.

Now I’ll try the move again, without the firing bit.

    def move(self, delta_time):
        for ship in self:
            ship.control_motion(delta_time)
            ship.move(delta_time, self)

This is essentially what I had before. Was I wrong to roll it back? No, becuase it was easy to do again and the clean slate reduced my need to think.

And I did give the method a better name for what it’ll do, control_motion.

class Ship:
    def control_motion(self, delta_time):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_f]:
            self.turn_left(delta_time)
        if keys[pygame.K_d]:
            self.turn_right(delta_time)
        if keys[pygame.K_j]:
            self.power_on(delta_time)
        else:
            self.power_off()

And remove those lines from the other control thing in Game:

    def control_ship(self, ship, dt):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q]:
            self.keep_going = True
            self.running = False
        if keys[pygame.K_k]:
            ship.fire_if_possible(self.missiles)
        else:
            ship.not_firing()

I think this will work. I know it’s passing the tests. It does not work: the controls have no effect. I didn’t create the right subclass, did I?

class Fleets:
    def __init__(self, asteroids, missiles, saucers, saucer_missiles, ships):
        self.fleets = (Fleet(asteroids), Fleet(missiles), Fleet(saucers), Fleet(saucer_missiles), ShipFleet(ships))

And the game works. Commit: Ship Fleet is now instance of ShipFleet, motion control done in Ship.move.

That may be enough for now. Let’s reflect and sum up.

Sumflection

The notion of a special kind of fleet came to mind while thinking about a better place to create new waves of asteroids. It seemed to make sense of a perpetually-refilling AsteroidFleet.

I then chose to do ShipFleet first, thinking that it would be easier than the AsteroidFleet, and it was pretty easy once I realized that, for now at least, I was better off leaving firing out of the move logic. I suspect that when there’s not much left but firing, we’ll find a better place for both the saucer and ship firing.

We can do slightly better with it now. We have this:

class Game:ck(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        self.check_next_wave(delta_time)
        self.control_ship(self.ship, delta_time)
        self.process_collisions()
        self.draw_everything()
        if self.game_over: self.draw_game_over()

    def check_saucer_firing(self, delta_time, saucers, saucer_missiles, ships):
        for saucer in saucers:
            saucer.fire_if_possible(delta_time, saucer_missiles, ships)

    def control_ship(self, ship, dt):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q]:
            self.keep_going = True
            self.running = False
        if keys[pygame.K_k]:
            ship.fire_if_possible(self.missiles)
        else:
            ship.not_firing()

Let’s pull out the firing bit and make check_ship_firing:

    def check_ship_firing(self, ship):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_k]:
            ship.fire_if_possible(self.missiles)
        else:
            ship.not_firing()

Call that new method, and with a bit of messing about we get this:

class Game:
    def asteroids_tick(self, delta_time):
        self.fleets.tick(delta_time)
        self.check_saucer_spawn(self.saucer, self.saucers, delta_time)
        self.check_ship_spawn(self.ship, self.ships, delta_time)
        self.check_saucer_firing(delta_time, self. saucers, self.saucer_missiles, self.ships)
        if self.ships: self.check_ship_firing(self.ship)
        self.check_next_wave(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_firing(self, ship):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_k]:
            ship.fire_if_possible(self.missiles)
        else:
            ship.not_firing()

    def control_game(self, ship, dt):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_q]:
            self.keep_going = True
            self.running = False

Tests are green, game works. This is a bit raggedy but it is a move in the right general direction.

Commit: Ship motion controls are in Ship, firing still in Game. Game controls (quit) broken out.

Summary

I took that last flyer and broke out ship firing. If due East was a perfect move, this was about Northeast or North Northeast, so in somewhat the right direction but not a perfect solution yet. That’s OK. We don’t proceed directly to perfect, we step from good to better, however the stepping-stones lead us, generally never moving away from our target but accepting that sometimes we have to back-track to get where we’re going.

With firing, I didn’t backtrack, it’s more a step to the side and a little bit forward. Of course prior to that I did completely back-track a step, and then duplicated it almost exactly right away.

Repeat, not quite the same way

I remember in Tai Chi class there was one move, I forget its name, perhaps it was Shan Tang Bei, where you had to step backward, winding up facing about 45 degrees to the rear. It was generally thought that you had to do it without tipping over, which made it particularly difficult.

To learn to do it, I repeated the move many many times until finally I found the right balance. Same here. I repeated the move, slightly differently, and got it right the second time. Much easier than Shan Tang Bei. I didn’t even tip over once.

We’re moving toward pushing more and more functionality down into Fleets and Fleet, now with the new option of specialized subclasses of Fleet to handle situations unique to a ship or saucer or missile. I’m not at all sure where we’ll wind up, but I think we’ll find that we have all, or almost all the logic in the object that it best belongs with.

Just now, we moved controlling the ship into the ship. Kind of makes sense if you think about it.

See you next time!