Python Asteroids+Invaders on GitHub

Trying to keep the Driver-Player simple. How well can I manage that?

I have all these great ideas about how an attract mode Driver strategy might work, and even about how the program might “learn” to run the Driver well. They are probably good ideas, but given the season and other distractions, heading right at them doesn’t seem to work for me. There’s too big a gap between the facilities that the game presently has, and the facilities that I think I need. And, of course, the facilities I really need are surely not quite the ones I think I need.

So this morning, I propose to make the Driver do roughly this: find an open area, not under a shield, where there is an invader. Probably find the nearest such area. Move there. Fire a lot, as often as possible.

I said simple. I will need the open areas. Presently Driver has this:

class Driver(Spritely, InvadersFlyer):
    shield_locations = ((198, 286), (378, 466), (558, 646), (738, 826))

You may recall that I got those numbers with a simple print during the initialization of the Invaders game. To get the open locations, I am going to type them in by hand, based on the numbers above, because that’s the simplest thing I can think of:

    shield_locations = ((198, 286), (378, 466), (558, 646), (738, 826))
    open_locations = ((0, 197), (287, 377), (467, 557), (647, 737), (827, 1024))

Now I want to test drive a little something. We’ll have to think about this a bit. We can get an interact_with_invaderfleet message. Let’s posit that we can ask the fleet some question. Let’s assume, for now, that we ask it to give us all the existing invaders. This is unquestionably not ideal, because we’re asking the fleet for its internal items, but it will be simple. We only care about the x coordinate of the invader, but it seems better to ask it for the positions (x, y). And, speculatively, we might care later about shooting the lowest or highest one.

So to TDD, I’ll work on a method that returns the nearest x value of an alien that is in an open area.

Let’s work up to it. Simply.

    # open_locations = ((0, 197), (287, 377), (467, 557), (647, 737), (827, 1024))
    def test_nearest_open_x(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        y = 512
        invader_positions = [(x,y) for x in x_values]
        nearest_invader_position = driver.nearest(invader_positions)
        assert nearest_invader_position[0] == 490

I think that’s good but a bit nasty. The y coordinate doesn’t matter. Let me code nearest.

    def nearest(self, positions):
        best = positions[0]
        best_dist = self.x_distance(positions[0])
        for position in positions:
            dist = self.x_distance(position)
            if dist < best_dist:
                best = position
                best_dist = dist
        return best

    def x_distance(self, position):
        return abs(self.position.x - position[0])

Green. I think it is fair to require all these things to be Vector2.

    def test_nearest_open_x(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        y = 512
        invader_positions = [Vector2(x,y) for x in x_values]
        nearest_invader_position = driver.nearest(invader_positions)
        assert nearest_invader_position.x == 490

Still green. Convert to vectors inside:

    def nearest(self, position_vectors):
        best = position_vectors[0]
        best_dist = self.x_distance(position_vectors[0])
        for position in position_vectors:
            dist = self.x_distance(position)
            if dist < best_dist:
                best = position
                best_dist = dist
        return best

    def x_distance(self, position_vector):
        return abs(self.position.x - position_vector.x)

I think we’ll commit this and then improve it. Commit: Driver can find nearest position.

What if we kept the best index instead of the best value? No, we don’t have that in the loop. I am tempted to push this into the Fleet, but it seems premature.

I think this isn’t simple enough. We don’t need the y coordinate and it is getting in the way.

Change the test and implementation.

    # open_locations = ((0, 197), (287, 377), (467, 557), (647, 737), (827, 1024))
    def test_nearest_open_x(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        nearest_invader_x = driver.nearest(x_values)
        assert nearest_invader_x == 490

Wait. The test isn’t really good enough, we’re not checking for open. Duh.

I think we’ll work from here anyway, the x coordinate is all we really care about. Keeping it simple, hoping to get simple enough for my brain this morning.

    # open_locations = ((0, 197), (287, 377), (467, 557), (647, 737), (827, 1024))
    def test_nearest_open_x(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        open_values = driver.x_in_open(x_values)
        assert len(open_values) == len(x_in)
        assert all([x in x_in for x in open_values])
        # nearest_invader_x = driver.nearest(x_values)
        # assert nearest_invader_x == 490

New method x_in_open reduces a list of x values to the ones in the open. Implement:

    open_locations = (range(0, 198), range(287, 378), range(467, 558), range(647, 738), range(827, 1024))

    def is_in_open(self, x):
        return any([x in r for r in self.open_locations])

    def x_in_open(self, x_values):
        in_any_open = [x for x in x_values if self.is_in_open(x)]
        return in_any_open

X is in the open if it is in any of the open locations, which I have converted to ranges for convenience.

The x_in_open name is weak. Rename.

    def select_values_in_open(self, x_values):
        in_any_open = [x for x in x_values if self.is_in_open(x)]
        return in_any_open

Now uncomment the nearest check and make it work. Let’s make it a new test, much like the old test.

    # open_locations = ((0, 197), (287, 377), (467, 557), (647, 737), (827, 1024))
    def test_find_open_xs(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        open_values = driver.select_values_in_open(x_values)
        assert len(open_values) == len(x_in)
        assert all([x in x_in for x in open_values])

    def test_find_nearest_open_x(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_in = (190, 290, 490, 690, 890)
        x_out = (250, 390, 590, 790)
        x_values = x_in + x_out
        nearest_invader_x = driver.nearest(x_values)
        assert nearest_invader_x == 490

First test renamed and shortened. Second one failing.

    def nearest(self, x_values):
        possibles = self.select_values_in_open(x_values)
        best = possibles[0]
        for x in possibles:
            if abs(self.position.x - x) < abs(self.position.x - best):
                best = x
        return best

Test green. Commit: better nearest.

What if there are no open x values? Or no values at all?

More tests.

    def test_no_open(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        x_out = (250, 390, 590, 790)
        nearest_invader_x = driver.nearest(x_out)
        assert nearest_invader_x == driver.position.x

I expect this to fail with a bad index:

>       best = possibles[0]
E       IndexError: list index out of range

Perfect. Code:

    def nearest(self, x_values):
        possibles = self.select_values_in_open(x_values)
        if not possibles:
            return self.position.x
        best = possibles[0]
        for x in possibles:
            if abs(self.position.x - x) < abs(self.position.x - best):
                best = x
        return best

Green. Commit: handle no x’s in open.

We should test what happens if there are no x’s provided. I think it should just work.

    def test_no_invaders(self):
        driver = Driver()
        driver.position = (500, u.INVADER_PLAYER_Y)
        no_invaders = []
        nearest_invader_x = driver.nearest(no_invaders)
        assert nearest_invader_x == driver.position.x

Green. Commit: test no invaders.

I am ready to try this in the Driver. Let’s review how it works now:

    def update(self, delta_time, fleets):
        if self._sprite.centerx < (378+286) / 2:
            centerx = self._sprite.centerx + self.step
            self.position = Vector2(centerx, self.position.y)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

This moves to the given position there, firing every second.

What we’d like to do instead is to move to the nearest open invader x, firing at will. (We will want to be more clever about firing, but that’s out of scope for now.)

I’m going to do this by intention, not with a test. Why? Well, I don’t see a decent test that will help me. I could be making a mistake.

We’re going to need to send a message to the InvaderFleet to get the x’s we need.

First let’s prepare the ground a bit: Extract variable:

    def update(self, delta_time, fleets):
        target = (378 + 286) / 2
        if self._sprite.centerx < target:
            centerx = self._sprite.centerx + self.step
            self.position = Vector2(centerx, self.position.y)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

I’m doing that because I plan to get a better target soon. Now, we might be either side of the target so let’s deal with that. Extract step I think …

    def update(self, delta_time, fleets):
        target = (378 + 286) / 2
        step = 0
        if self._sprite.centerx < target:
            step = self.step
        elif self._sprite.centerx > target:
            step = -self.step
            centerx = self._sprite.centerx + step
            self.position = Vector2(centerx, self.position.y)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

NOw we move in the right direction or stay where we are. Extract that whole bit:

    def update(self, delta_time, fleets):
        target = (378 + 286) / 2
        self.step_toward(target)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

    def step_toward(self, target):
        step = 0
        if self._sprite.centerx < target:
            step = self.step
        elif self._sprite.centerx > target:
            step = -self.step
            centerx = self._sprite.centerx + step
            self.position = Vector2(centerx, self.position.y)

Now I’ll test that in the game just to be sure I’m ok. I am not, the thing doesn’t move. And I’ve taken rather a large bite without testing. Arrgh. Oh. Too much under the if.

    def step_toward(self, target):
        step = 0
        if self._sprite.centerx < target:
            step = self.step
        elif self._sprite.centerx > target:
            step = -self.step
        centerx = self._sprite.centerx + step
        self.position = Vector2(centerx, self.position.y)

Whew. I really didn’t want to roll back. Works. Commit: refactoring.

OK, let’s get that X position. We can TDD some of that on the InvaderFleet tests.

    def test_get_invader_xs(self):
        fleet = InvaderFleet()
        values = fleet.invader_x_values()
        assert len(values) == 9

Not much of a test but enough.

class InvaderFleet(InvadersFlyer):
    def invader_x_values(self):
        return self.invader_group.invader_x_values()

class IinvaderGroup:
    def invader_x_values(self):
        return {invader.position.x for invader in self.invaders}

Test fails, but it’s because there are 11 columns of invaders. Change test.

    def test_get_invader_xs(self):
        fleet = InvaderFleet()
        values = fleet.invader_x_values()
        assert len(values) == 11

Green. Now change update to use this wonderful new ability.

    def update(self, delta_time, fleets):
        target = self.nearest(self.invader_fleet.invader_x_values)
        self.step_toward(target)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

This doesn’t work yet, because we do not have the InvaderFleet. And we might not have it ever, so let’s do this:

    def update(self, delta_time, fleets):
        target = self.nearest(self.invader_x_values)
        self.step_toward(target)
        self.count = (self.count + 1) % 60
        if not self.count:
            fleets.append(PlayerShot(self._sprite.center))

We now need to initialize that and update it:

class Driver(Spritely, InvadersFlyer):

    def __init__(self):
        self._sprite = Sprite.player()
        self.step = 4
        half_width = self._sprite.width / 2
        self.left = 64 + half_width
        self.right = 960 - half_width
        self.position = Vector2(self.left, u.INVADER_PLAYER_Y)
        self.count = 0
        self.invader_x_values = []

    def begin_interactions(self, fleets):
        self.invader_x_values = []

    def interact_with_invaderfleet(self, fleet, fleets):
        self.invader_x_values = fleet.invader_x_values()

This should work, and it works rather well. Long video follows.

That’s nearly good! Let’s sum up!

Summary

I did manage to get some solid tests in for most of this, though there is nothing testing the driver update method itself. We can probably extract a method or two here and do a bit of testing, and it would probably be wise to do so.

I think that creating the open ranges by hand was wise. I could have done a bunch of clever stuff with the interactions in Driver to capture the Shields and get their actual ranges and then create the open spaces. (I suppose we could have done not_over_shield to avoid needing the open spaces.) Anyway doing them by hand was easy and gave me what I really needed.

I do not quite understand why a few of those shots hit a shield, but it might just be because it fires every second and we just happened to be there. We’ll probably work on the firing anyway, so if there’s an issue it will turn up.

As far as an attract mode goes, this is pretty good already. We need to get the invader shots to come out: they’re not firing because they don’t recognize the driver as a player. And we go to Game Over too rapidly, I noticed that we don’t wait for the last PlayerExplosion to finish. We’ll make a card for that.

But this is good. I tried to keep it simple, and that got me focused down on just worrying about the x coordinates, which is, on the one hand, kind of nitty gritty, but on the other hand exactly what a driven player needs to know: it only has an x coordinate to think about.

I am pleased, especially with how well it works.

See you next time!