Python Asteroids+Invaders on GitHub

I think today we’ll deal with the possibility of running out of invaders. Today we bite the bear. Take that, bear!

One might think that, as someone who has spent a great deal of time in the past few years implementing replicas of old video games, I’d be good at playing them. In fact I am not. Never really was. But sometimes even I can banish all the invaders, and when that happens, the game just continues to cycle with no invaders. Since the saucer only runs when there are eight or more invaders, the game becomes rather boring at that point, and it never ends.

What is supposed to happen is that if you destroy all the invaders, after a discreet interval, a new rack of invaders appears. And that rack is supposed to start a bit further down the screen.

Today, sitting here before having looked at the code, my plan is to implement that feature.

What Happens Now?

In particular, where do we get the first rack? The game starts with a coin:

coin.py

def invaders(fleets):
    fleets.clear()
    left_bumper = u.BUMPER_LEFT
    fleets.append(Bumper(left_bumper, -1))
    fleets.append(Bumper(u.BUMPER_RIGHT, +1))
    fleets.append(TopBumper())
    fleets.append(InvaderFleet())
    fleets.append(PlayerMaker())
    fleets.append(ShotController())
    fleets.append(InvaderScoreKeeper())
    fleets.append(RoadFurniture.bottom_line())
    fleets.append(TimeCapsule(10, InvadersSaucerMaker()))
    for i in range(3):
        fleets.append(ReservePlayer(i))
    half_width = 88 / 2
    spacing = 198
    step = 180
    for i in range(4):
        place = Vector2(half_width + spacing + i * step, u.SHIELD_Y)
        fleets.append(RoadFurniture.shield(place))

That’s a bit more programming than the asteroids coins. Those just drop in SaucerMaker, ScoreKeeper, Thumper, WaveMaker, and ShipMaker. Back when doing Asteroids, I was all about doing everything with objects running inside the game, and here, I’ve reached a different balance. We could have a ReserveMaker that took a parameter 3 and when it ran, created reserves, but really … why? Same with the shields.

Anyway, we are here to figure out new racks of Invaders. Where do they come from? Odds are, it’s the InvaderFleet:

class InvaderFleet(InvadersFlyer):
    def __init__(self):
        self.step = Vector2(8, 0)
        self.down_step = Vector2(0, 32)
        self.invader_group = InvaderGroup()
        self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
        self.invader_group.position_all_invaders(self.origin)
        self.direction = 1
        self.step_origin()

Looks like the Fleet defers that to the group:

class InvaderGroup:
    def __init__(self):
        self.invaders = []
        self.create_invaders()
        self._next_invader = 0

    def create_invaders(self):
        self.invaders = []
        for x in range(55):
            col = x % 11
            row = x // 11
            sprite = Sprite.invader(row)
            self.invaders.append(Invader(col, row, sprite))

    def position_all_invaders(self, origin):
        for invader in self.invaders:
            invader.move_relative_to(origin)

Right. So one way this could go is that when we notice there are no invaders left, we just toss a new InvaderFleet into the mix and it will create and run a new batch. It would be wise to remove the existing one, of course.

It seems clear that the invaders are not ready to go until after they have been positioned. We should ask ourselves why that is done in a separate operation, where we first create the Group and then position it. Beck’s Complete Construction Method pattern would advise us to do it all at once. Possibly we were thinking, early on, that the Group would just be reset somehow.

Huh. Truth be told, we could do this: if the InvaderFleet notices that it is out of invaders, it could just create and initialize a new InvaderGroup inside itself. There are concerns we could raise about that:

  • It’s not consistent with the overall style of this framework, where we toss various bombs into the mix and stuff happens;
  • It would make the Fleet more mutable than it already is, at a different rhythm;
  • It isn’t clear how to manage the discreet delay before doing the creation.

That last one is the one that convinces me to stick with the original plan. If, upon noticing that it is out of invaders, the InvaderFleet would remove itself, and toss in a TimeCapsule containing a new InvaderFleet, the discreet delay would be dealt with and everything would work out just fine.

How might the InvaderFleet know that this has occurred?

It already knows that the Group has cycled through all the invaders:

    def update(self, delta_time, _fleets):
        result = self.invader_group.update_next(self.origin)
        self.process_result(result)

    def process_result(self, result):
        if result == CycleStatus.CONTINUE:
            pass
        elif result == CycleStatus.NEW_CYCLE:
            self.step_origin()
        elif result == CycleStatus.REVERSE:
            self.reverse_travel()

Couldn’t the Group return a new status, NO_MORE_INVADERS, or something? Then right there in process_result we’d be equipped to do our new thing, though we would need to pass in the main Fleets variable. Easily done as we have it in update.

Check the group:

class InvaderGroup:
    def update_next(self, origin):
        return self.perform_update_step(origin)

    def perform_update_step(self, origin):
        if self._next_invader < len(self.invaders):
            self.move_one_invader(origin)
            return CycleStatus.CONTINUE
        else:
            return self.end_cycle()

    def end_cycle(self):
        self._next_invader = 0
        return CycleStatus.REVERSE if self.any_out_of_bounds() else CycleStatus.NEW_CYCLE

I think we have a plan. Let’s see about having a test, first one that causes the Group to return a new CycleStatus.

I add the status first, just because I happen to think of it:


class CycleStatus(Enum):
    CONTINUE = "continue"
    NEW_CYCLE = "new cycle"
    REVERSE = "reverse"
    EMPTY = "empty"

EMPTY made more sense to me than NO_MORE_INVADERS. But the real reason I was looking at CycleStatus was to find the tests that refer to it. There are at least three:

    def test_update_next(self):
        group = InvaderGroup()
        origin = Vector2(100, 100)
        for i in range(55):
            result = group.update_next(origin)
            assert result == CycleStatus.CONTINUE
        result = group.update_next(origin)
        assert result == CycleStatus.NEW_CYCLE

    def test_no_reversal(self):
        group = InvaderGroup()
        group.position_all_invaders(Vector2(u.BUMPER_LEFT + 100, 512))
        result = group.end_cycle()
        assert result == CycleStatus.NEW_CYCLE

    def test_reversal(self):
        group = InvaderGroup()
        bumper = Bumper(u.BUMPER_RIGHT, +1)
        invader = group.invaders[0]
        _pos_x, pos_y = invader.position
        invader.position = (u.BUMPER_RIGHT, pos_y)
        result = group.end_cycle()
        assert result == CycleStatus.REVERSE
        invader.position = (u.BUMPER_LEFT, pos_y)
        assert result == CycleStatus.REVERSE

No, four, and this one is quite interesting:

    def test_remove_last_invader(self):
        group = InvaderGroup()
        for count in range(55):
            group.kill(group.invaders[0])
        result = group.update_next(Vector2(0, 0))
        assert result == CycleStatus.NEW_CYCLE

We want to test that if all but one is removed, we get NEW_CYCLE and if they are all removed, we get EMPTY.

Duplicate this test and make two:

    def test_remove_penultimate_invader(self):
        group = InvaderGroup()
        for count in range(54):
            group.kill(group.invaders[0])
        result = group.update_next(Vector2(0, 0))
        result = group.update_next(Vector2(0, 0))
        assert result == CycleStatus.NEW_CYCLE

Here, we are down to one and we end the cycle with NEW_CYCLE, as intended. The original test wants to be:

    def test_remove_last_invader(self):
        group = InvaderGroup()
        for count in range(55):
            group.kill(group.invaders[0])
        result = group.update_next(Vector2(0, 0))
        assert result == CycleStatus.EMPTY

It fails, as we might expect, and we implement:

    def end_cycle(self):
        self._next_invader = 0
        if self.any_out_of_bounds():
            return CycleStatus.REVERSE
        elif len(self.invaders) > 0:
            return CycleStatus.NEW_CYCLE
        else:
            return CycleStatus.EMPTY

Our test passes. We’re returning the correct new status. What does the game do with it and why is no other test failing? (Answer: probably because we have insufficient testing for the InvaderFleet.)

    def process_result(self, result):
        if result == CycleStatus.CONTINUE:
            pass
        elif result == CycleStatus.NEW_CYCLE:
            self.step_origin()
        elif result == CycleStatus.REVERSE:
            self.reverse_travel()

We do have some tests for process_result, I think:

    def test_ok_leaves_step_alone(self):
        fleet = InvaderFleet()
        origin = fleet.origin
        fleet.process_result(CycleStatus.CONTINUE)
        assert fleet.origin == origin

    def test_end_increments_step(self):
        fleet = InvaderFleet()
        origin = fleet.origin
        fleet.process_result(CycleStatus.NEW_CYCLE)
        assert fleet.origin == origin + fleet.step

    def test_end_at_edge_steps_down_and_left(self):
        fleet = InvaderFleet()
        origin = fleet.origin
        direction = fleet.direction
        fleet.process_result(CycleStatus.REVERSE)
        assert fleet.direction == -direction
        assert fleet.origin == origin - fleet.step + fleet.down_step

We can write a new one. Back when we put in this feature, we thought about whether the process_result method should throw an exception if it received a return that wasn’t among the values it expects. We decided not to do that because it would crash the game and we don’t want to put code in that will crash the game.

So a new test:

    def test_end_empty(self):
        fleets = FakeFleets()
        invader_fleet = InvaderFleet()
        origin = invader_fleet.origin
        invader_fleet.process_result(CycleStatus.EMPTY, fleets)
        assert invader_fleet in fleets.removes
        added = fleets.appends[0]
        assert isinstance(added, TimeCapsule)

First issue is that process_result isn’t expecting fleets. Fix that:

    def update(self, delta_time, _fleets):
        result = self.invader_group.update_next(self.origin)
        self.process_result(result, _fleets)

The other tests pass None, which is safe if a bit naff. Now we must implement:

    def process_result(self, result, fleets):
        if result == CycleStatus.CONTINUE:
            pass
        elif result == CycleStatus.NEW_CYCLE:
            self.step_origin()
        elif result == CycleStatus.REVERSE:
            self.reverse_travel()
        elif result == CycleStatus.EMPTY:
            fleets.remove(self)
            new_fleet = InvaderFleet()
            capsule = TimeCapsule(2, new_fleet)
            fleets.append(capsule)

Our test passes. Try the game, hoping to be able to remove all the invaders. I have to cheat, which I do by initializing with only 12 invaders. It does work. Commit: Game creates new rack two seconds after previous rack completed.

Summary

Sometimes you bite the bear, and today was one of those days. This has gone as nicely as one could ask. went with the more classic style, tested creation of the new enum value, tested using the new value, done. We have not tested the contents of the TimeCapsule. Should we do that? OK.

    def test_end_empty(self):
        fleets = FakeFleets()
        invader_fleet = InvaderFleet()
        invader_fleet.process_result(CycleStatus.EMPTY, fleets)
        assert invader_fleet in fleets.removes
        added = fleets.appends[0]
        assert isinstance(added, TimeCapsule)
        assert isinstance(added.to_add, InvaderFleet)
        assert added.time == 2

Happy now? I am.

We have left the InvaderGroup with its two-phase init. Our work didn’t take us there, and we chose not to divert just because we were in the classes. That’s OK, and we might even get there tomorrow. One never knows, do one?

Before we move on to the next part of the story, starting the racks lower and lower, I’d like to do some research first, to see what the original game did in that regard. And this article is long enough anyway.

See you next time!