Python Asteroids on GitHub

Let’s push more behavior down into the objects. There’s plenty of room at the bottom. (I nearly interfere with the canine, but finally make a little progress. Don’t read this article.)

The asteroids_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)
        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 do … check_saucer_spawn, see if we can push that down a bit.

    def check_saucer_spawn(self, saucer, saucers, delta_time):
        if saucers: return
        self.saucer_timer.tick(delta_time, saucer, saucers)

    def init_saucer_timer(self):
        self.saucer_timer = Timer(u.SAUCER_EMERGENCE_TIME, self.bring_in_saucer)

    def bring_in_saucer(self, saucer, saucers):
        saucer.ready()
        saucers.append(saucer)

Let’s think about where this could take place. It can’t be in saucer, because there is no saucer if this has to happen. It could easily be in Fleets, because Fleets (currently) knows which collection is saucers. If we had a SaucerFleet subclass, it could be there, done in its tick event.

That seems to be the right place.

Let’s try to test-drive this.

    def test_saucer_spawn(self):
        saucers = []
        saucer_fleet = SaucerFleet(saucers)
        saucer_fleet.tick(0.1)
        assert not saucers
        saucer_fleet.tick(u.SAUCER_EMERGENCE_TIME)
        assert saucers

That looks a lot like just the thing.

However, there’s an issue. Where will the SaucerFleet get a saucer? And how will we know the direction, as needed in saucer.ready:

    def ready(self):
        self.direction = -self.direction
        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.set_firing_timer()
        self.set_zig_timer()

Probably our best bet is to create a new Saucer every time. If we had a class variable direction, we could use that to set the direction.

Darn. I was hoping for something easier.

We make the class variable here:

class Saucer:
    direction = -1

    def __init__(self, position=None, size=2):
        self.position = position if position is not None else u.CENTER
        self.size = size
        self.velocity = u.SAUCER_VELOCITY
        self.directions = (self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45))
        self.radius = 20
        raw_dimensions = Vector2(10, 6)
        saucer_scale = 4 * self.size
        self.offset = raw_dimensions * saucer_scale / 2
        saucer_size = raw_dimensions * saucer_scale
        self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)
        self.set_firing_timer()
        self.set_zig_timer()

Now all references to self.direction refer to the class value. But some of the work is done in ready:

    def ready(self):
        self.direction = -self.direction
        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.set_firing_timer()
        self.set_zig_timer()

If we were to merge this code with the __init__, we’d be most of the way there. Note that I’m doing this in the existing code. I’ve put off doing my SaucerFleet changes.

I had more trouble than I expected, in part due to how class variables work. You don’t say self.direction, you must say Saucer.direction, apparently.

With my new scheme, I should not create a saucer for the game to hold.

When I init for a new game, I should init the class, to ensure the saucer always starts left to right:

class Game:
    # noinspection PyAttributeOutsideInit
    def game_init(self):
        self.running = True
        Saucer.init_for_new_game()
        self.insert_quarter(u.SHIPS_PER_QUARTER)

That becomes:

class Saucer:
    @classmethod
    def init_for_new_game(cls):
        cls.direction = -1

The Timer method becomes:

class Game:
    def bring_in_saucer(self, saucer, saucers):
        saucers.append(Saucer())

This is working, but tests are failing. In part that’s because we no longer have a saucer to pass into bring_in_saucer, it creates its own.

Still ten tests failing. Better go see why.

The first one I check is this:

    def test_ready(self):
        saucer = Saucer()
        saucer.ready()
        assert saucer.position.x == 0
        assert saucer.velocity == u.SAUCER_VELOCITY
        assert saucer.missile_timer.elapsed == 0
        saucer.missile_timer.elapsed = 0.5
        saucer.ready()
        assert saucer.position.x == u.SCREEN_SIZE
        assert saucer.velocity == -u.SAUCER_VELOCITY
        assert saucer.zig_timer.delay == u.SAUCER_ZIG_TIME
        assert saucer.zig_timer.elapsed == 0
        assert saucer.missile_timer.elapsed == 0

We no longer use ready. But we do create more than one saucer and they should reverse. Let’s revise this test a bit.

    def test_ready(self):
        saucer = Saucer()
        assert saucer.position.x == 0
        assert saucer.velocity == u.SAUCER_VELOCITY
        assert saucer.missile_timer.elapsed == 0
        saucer.missile_timer.elapsed = 0.5
        saucer = Saucer()
        assert saucer.position.x == u.SCREEN_SIZE
        assert saucer.velocity == -u.SAUCER_VELOCITY
        assert saucer.zig_timer.delay == u.SAUCER_ZIG_TIME
        assert saucer.zig_timer.elapsed == 0
        assert saucer.missile_timer.elapsed == 0

Works. Rename it to test_alternating_direction.

I spend a long time getting the rest of the tests to run, No substantive changes, it’s just that I removed Saucer.ready and changed Saucer so that you do not init it with a position.

That broke about ten tests. And confused me for a while. I haven’t even moved the capability down to the fleet yet but now we use a new Saucer every time:

class Game:
    def init_saucer_timer(self):
        self.saucer_timer = Timer(u.SAUCER_EMERGENCE_TIME, self.bring_in_saucer)

    # noinspection PyAttributeOutsideInit
    def init_fleets(self):
        asteroids = []
        missiles = []
        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)

    def bring_in_saucer(self, saucers):
        saucers.append(Saucer())

class Saucer:
class Saucer:
    direction = -1

    @classmethod
    def init_for_new_game(cls):
        cls.direction = -1

    def __init__(self, position=None, size=2):
        self.size = size
        Saucer.direction = -Saucer.direction
        x = 0 if Saucer.direction > 0 else u.SCREEN_SIZE
        self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
        self.velocity = Saucer.direction * u.SAUCER_VELOCITY
        self.directions = (self.velocity.rotate(45), self.velocity, self.velocity, self.velocity.rotate(-45))
        print(Saucer.direction, self.velocity, self.directions)
        self.radius = 20
        raw_dimensions = Vector2(10, 6)
        saucer_scale = 4 * self.size
        self.offset = raw_dimensions * saucer_scale / 2
        saucer_size = raw_dimensions * saucer_scale
        self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)
        self.set_firing_timer()
        self.set_zig_timer()

I’m finally green and the game works as advertised. Commit: change game to use new Saucer every time around.

I’ve noticed that when the saucer goes off screen at top or bottom, it doesn’t wrap around. I finally realize that’s because I don’t wrap it explicitly.

I’ll just make a sticky. My brain is fried.

Don’t read this article. Read the next one.

Summary

With a new Saucer every time, we should probably create its surface only once. That, too, can be in the future.

What have I learned? I think the main thing I should have learned was that I should have rolled back and walked away. But I powered through instead. That works also and we’ll be revisiting this code to change it, tonight or tomorrow anyway.

Thanks for not reading this. See you next time!