Python Asteroids on GitHub

Some of what I did last time needs improvement. This is no surprise, but, really, I coulda shoulda woulda done better. I forgive me.

No sooner had I sat down to read than I realized that some of what I did yesterday needs improvement. Of course, even when we are perfect, we could use a little improvement, so it was no surprise. But I think what we’ll find this morning is clearly asking to be better.

I had just had the idea of Fleet subclasses, and built the ShipFleet this way:

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

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

OK, for now at least, our rule is that Fleet.move sends move to all the fleet’s flyers. But what’s this about sending control_motion? We should leave that up to the move in Ship.

I’ll remove this method from ShipFleet but keep the class, because I foresee the need for it. And I’ll move the control_motion to Ship.

class Ship:
    def move(self, delta_time, _ships):
        self.control_motion(delta_time)
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

I’m aware that all these flyer objects tend to have those four position lines, but I’m not going to worry about that just now.

There’s a test that fails:

    def test_ship_move(self):
        ship = Ship(Vector2(50, 60))
        ship.velocity = Vector2(10, 16)
        ship.move(0.5, [ship])
        assert ship.position == Vector2(55, 68)

The error is pygame not initialized. Fortunately, there is pygame.get_init().

    def control_motion(self, delta_time):
        if not pygame.get_init():
            return
        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()

I think we’ll allow that. Commit: move control_motion call to Ship. Remove ShipFleet.move.

So that’s better. What would be better still, I think, would be if all our flyers were to do their work on tick rather than move. The main asteroids event is this:

    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()

And:

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

    def move_everything(self, delta_time):
        for fleet in self.fleets:
            fleet.move(delta_time)

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

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

OK, before we go nuts here, let’s think about this. As written, we tick everyone and then move everyone. If we push move down into the individual objects’ tick method, the first object will do all its tick things and move before the second object gets to do anything.

I think that’s OK, because we’re not doing interactions on tick, we do the process_collisions later on. OK.

New rule: all the flyers should move on tick. We’ll remove the move_everything from Fleets, and move from Fleet.

class Fleet:
    def tick(self, delta_time):
        for flyer in self:
            flyer.tick(delta_time)

That only breaks one test so far, because FakeFlyer doesn’t know tick.

Also I need to accumulate the T/F in the code above. And … we need to pass the fleet to tick, because move wants it.

class Fleet:
    def tick(self, delta_time):
        result = True
        for flyer in self:
            result = result and flyer.tick(delta_time, self)
        return result

Now we’re green. Let’s fix up the flyers.

class Asteroid:
    def tick(self, delta_time, fleet):
        self.move(delta_time, fleet)
        return True

class Missile:
    def tick(self, delta_time, fleet):
        self.move(delta_time, fleet)
        return True

class Saucer:
    def tick(self, delta_time, fleet):
        self.move(delta_time, fleet)
        return True

class Ship:
    def tick(self, delta_time, fleet):
        self.move(delta_time, fleet)
        return True

That doesn’t look like much improvement, but I think it is, because now we’re calling a more generic function, tick, not a specific one, move. And we could make move private if we were so inclined, although there are at least a few tests of it.

We’ll see, though. I do not guarantee to be satisfied tomorrow with what I do today.

We’re green and the game should be working. Commit: flyers move on tick, no direct calls to move outside tests.

Let’s pause to think:

Cortical-Thalamic Pause

Our more or less fundamental purpose here is to push responsibility down from Game into Fleets, Fleet, or the individual flyers, as far as it will go. Presumably, all the responsibility will be pushed into tick, or somewhere called by tick.

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()

Let’s see if we can move ship firing downward.

class Game:
    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()

Why can’t we do that on tick? Let’s assume that we can and remove this code and the call from Game.

Now in Ship, presumably on tick, we’ll want to do the thing.

class Ship:
    def tick(self, delta_time, fleet):
        self.move(delta_time, fleet)
        return True

    def move(self, delta_time, _ships):
        self.control_motion(delta_time)
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

    def control_motion(self, delta_time):
        if not pygame.get_init():
            return
        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()

If we move the check on the firing key, “k”, we’ll need to have access to the missiles collection. I have an idea for how we’ll do that.

Let’s assume that our space object flyers have “sensors” that let them find out things about the universe. To give them that access, let’s pass not just the object’s own fleet to it on tick, but also the Fleets object. We’ll allow certain callbacks to Fleets, to support what the objects need to know.

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

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

I change most of the tick calls to ignore the fleets parm but in Ship:

class Ship:
    def tick(self, delta_time, fleet, fleets):
        self.move(delta_time, fleet, fleets)
        return True

    def move(self, delta_time, _ships, fleets):
        self.control_motion(delta_time, fleets.missiles)
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

    def control_motion(self, delta_time, missiles):
        if not pygame.get_init():
            return
        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()
        if keys[pygame.K_k]:
            self.fire_if_possible(missiles)
        else:
            self.not_firing()

Some tests are broken. I was afraid to use Change Signature because of the differing uses of tick at different levels.

I’ll fix them up.

    def test_ship_move(self):
        ship = Ship(Vector2(50, 60))
        ship.velocity = Vector2(10, 16)
        ship.move(0.5, [ship])
        assert ship.position == Vector2(55, 68)

Since the ship is checking controls on move, we can’t just pass an empty fleets here. Let’s change Ship:

class Ship:
    def tick(self, delta_time, fleet, fleets):
        self.control_motion(delta_time, fleets.missiles)
        self.move(delta_time, fleet)
        return True

    def move(self, delta_time, _ships):
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

That makes more sense anyway, I think. Moving is moving, controlling is controlling.

    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

That’s because FakeFlyer doesn’t expect the new parm:

class FakeFlyer:
    def tick(self, _delta_time, _fleet, _fleets):
        return True

We’re green and good. Commit: The Fleets instance is passed to all space objects’ tick method. tick(delta_time, fleet, fleets).

Let’s sum up.

Summary

Are we better off?

I think we are. The big decision, though we made it casually, was to pass the Fleets instance down to Fleet.tick and on down to the tick method of all the flyers. That allows the flyer to manipulate its own fleet, to remove itself if need be, or to add. And the object can assess other fleets, as the Ship does to decide whether it can fire. It can only fire if there are fewer than four missiles in action.

(I’m wondering if we’ll actually make use of the object’s own fleet. We might not, and of course since we get the Fleets now, we could get it if we wanted it. We’ll keep an eye out for that. Offhand, I’m not seeing why we need access to our own fleet on tick. We will access it during collisions, but that’s separate from tick.)

We’re moving more slowly than I had anticipated. I thought it would be just two or maybe three sessions until most everything was moved down. It’s clearly taking more, in part because there’s more to say about each step, but in part because each step is itself usually broken into a few smaller steps. That’s a good thing, because larger steps are disproportionately inclined to include mistakes.

Looking at the main asteroids_tick, I think we’re on a path to have it come down to just a few bits:

  1. Tick all the Fleets;
  2. Check game controls (check for quit);
  3. Process collisions;
  4. Draw.

Everything else will move down into Fleets, Fleet, or the individual objects. Remaining to be done right now are:

        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)

I expect those will go, respectively, to SaucerFleet, ShipFleet, Saucer, and AsteroidsFleet.

But we’ll see. I bet we can do the move in small steps, moving things first to Fleets and then to a specific Fleet and then, in one case, to Saucer.

I’m interested to find out what I do. I hope you’ll join me!