Python Asteroids+Invaders on GitHub

Bruce gave me a very nice idea: Bumpers! I think I’ll try it.

Bruce Onder1 sent me this note on Mastodon:

WRT the left and right columns - what if you implement invisible bumpers and detect collisions on them with the invaders?

This is, of course, brilliant. The fundamental notion of the “decentralized” design we’re using here is that there are these objects in the mix and they interact and decide what to do based on what they interact with. So a bullet interacts with a ship, and if they’re close enough together, the ship and bullet destroy themselves.

Naively, I was thinking that I’d have to have some logic in my InvaderFleet to check the coordinates of the outermost invaders and reverse their travel if they had gone far enough. I mentioned yesterday something about how that might be done. Bruce’s idea is much more i line with the overall design. To repeat it with a bit more detail:

  1. Put two invisible vertical rectangles where the boundaries should be;
  2. Let them interact with all the invaders;
  3. If an invader hits a rectangle, tell the InvaderFleet to reverse;
  4. When the movement cycle is over, InvaderFleet checks the flag and adjusts the movement.

So fine. I like it. I’m glad Bruce thought of it, because I don’t think I would have. Here again we see the value of collaboration vs working alone.

So let’s get started doing that. We’re a ways away from it, but maybe not as far as it looks.

All we have right now is our fixed display of dots:

dots

As a first step, I want to put a yellow square behind the dots. The reason is that I want to check out my understanding of how pygame rectangles work.

class Invader:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def draw(self, screen, start, step):
        pos = (start.x+self.x*step, start.y - step*self.y)
        rect = pygame.Rect(0, 0, 32, 32)
        rect.center=pos
        pygame.draw.rect(screen, "yellow", rect)
        pygame.draw.circle(screen, "red", pos, 16)

Note that we’ve defined the rectangle to be top-left at 0, 0 and size 32, 32. But then we set its center to be the same as the position of our dot. We get a nice yellow background behind our red dots:

red invader dots each with yellow background

You may be wondering why I did that. The reason is that we’ll need our invaders to have a rectangle to check for collision with our bumpers, and I wanted to see whether I could create that rectangle in that simple center-oriented way or whether I’d have to compute an offset and such. In the past, I’ve done it with offsets, because I didn’t know about that aspect of Rect.

Now, let’s create a Bumper and an Invader and see if we can make them detect a collision.

I should mention that if this scheme seems kind of “indirect”, that is intentional. It’s part of this design scheme that the logic of the game is down in the individual objects. It would be OK for InvaderFleet to do logic, but our design considers it “better” to defer the decisions down to individual objects. Our Invaders are not full-fledged Flyer subclasses, at least not now, but in principle they could be, and we want to treat them as if they were.

Here’s a sketch test:

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper = Bumper(16)
        invader = Invader(5, 2)
        start = Vector2(64, 512)
        step = 64
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper)
        assert not fleet.reverse

We have a number of false assumptions in here:

  1. There is no Bumper class. I’m supposing the input is its x coordinate;
  2. Invader expects a surface to draw on and I’m passing None. My plan is that Invader will check its surface and not draw if there is None, allowing this test to run.
  3. Invader doesn’t know interact_with_bumper yet.
  4. InvaderFleet doesn’t have reverse.

We’ll tick through those. I create the Bumper as a Flyer and let PyCharm give it all the abstract methods. I think that’s going to be the most irritating part of this implementation. Perhaps we should have two different Flyer subclasses, AsteroidsFlyer and InvadersFlyer. (And there might be some objects that are there for both and inherit from Flyer.) That sounds like a good idea. I’ll probably do it but for now we’ll move on.

I’m planning that the Bumper will be outside the InvaderFleet, so it will not need to interact with Invader but it will need to ignore InvaderFleet. I include a pass method for that.

Change Invader not to draw if screen is None:

class Invader:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.rect = pygame.Rect(0, 0, 32, 32)

    def draw(self, screen, start, step):
        pos = (start.x+self.x*step, start.y - step*self.y)
        self.rect.center = pos
        if screen:
            pygame.draw.rect(screen, "yellow", self.rect)
            pygame.draw.circle(screen, "red", pos, 16)

I’ve promoted the rectangle to a member variable. What I’m up to is that the Invader will maintain her rectangle whenever she moves. But she doesn’t move yet, so I’m maintaining it in draw for now. We’ll inch it over soon.

My test should be executing and failing now, if I would be kind enough to import Bumper into it.

Darn. PyCharm wanted to restart and now I’ve lost my pinned test runs. Hold on, I’ll put them back. Little interruptions like this derail one’s thoughts. I’ll try to find the rails soon.

Hm a bunch fail. Whazzup with that? Oh the darn checkers for missing methods again. I’m going to disable those but I need to clean them up because they do have a bit of value.

    @pytest.mark.skip("needs updating")

Nice, and I even get an ignored count when they run.

OK, now I’m getting what I expected:

>       invader.interact_with_bumper(bumper)
E       AttributeError: 'Invader' object has no attribute 'interact_with_bumper'

And we can do that.

class Invader:
    def interact_with_bumper(self, bumper, invader_fleet):
        if bumper.rect.colliderect(self.rect):
            invader_fleet.at_edge()

class InvaderFleet(Flyer):
    def __init__(self):
        self.invaders = [Invader(x//5, x % 5) for x in range(55)]
        self.reverse = False

    def at_edge(self):
        self.reverse = True

I think the test might be green now. Well it would be if it were correctly written.

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper = Bumper(16)
        invader = Invader(5, 2)
        start = Vector2(64, 512)
        step = 64
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse

Now to get an Invader to collide. Ideally, this one. I’ll have to do some arithmetic.

We want start such that Invader(5, 2) has his rectangle intersecting with (16,0 17,1024), and he sets his position to

pos = (start.x + step*self.x, start.y - step*self.y)

So we want start.x + 645 = 16, so start.x = 16 - 645 = -304. Maybe.

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper = Bumper(16)
        invader = Invader(5, 2)
        start = Vector2(64, 512)
        step = 64
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse
        start = Vector2(-304, 512)
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert fleet.reverse

This passes. Let’s make it a bit more clear where that -304 came from:

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper_x = 16
        bumper = Bumper(bumper_x)
        invader_column = 5
        invader = Invader(invader_column, 2)
        start = Vector2(64, 512)
        step = 64
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse
        start_x = bumper_x - invader_column*step
        start = Vector2(start_x, 512)
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert fleet.reverse

Better. Also green. Commit: InvaderFleet and Invader begin to use Bumper to detect edges.

We’ll set that aside and work on movement. The game cycle, managed in Fleets, is update, interactions, tick, draw. We want to move our invaders on update.

I guess we should test-drive this.

Before I get started, I want to change Invader. We’re using x and y in there and they are really column and row. We’ll want to keep those notions distinct. That’s done. We’ll see that in a moment.

Now how do we want InvaderFleet to move the invaders? Given the bumpers, I think it will just have a point (Vector2) that it will increment, and tell all its invaders to move_relative to that point. The (0, 0) invader will move right there, etc.

Something like this:

Arrgh. Pygame has bitten me hard when I renamed those variables. Roll back. Do the change locally. Commit: rename invader members x,y to row,column.

Back to moving. Let’s work on InvaderFleet, showing that it has and can maintain its origin point.

    def test_fleet_origin(self):
        fleet = InvaderFleet()
        assert fleet.origin == Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)

Lots of magic there and in fact it’s copied from what I just did in InvaderFleet:

class InvaderFleet(Flyer):
    def __init__(self):
        self.invaders = [Invader(x//5, x % 5) for x in range(55)]
        self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
        self.reverse = False

And I think it would be wise to move the invaders during init. I’ll extend my test:

    def test_fleet_origin(self):
        fleet = InvaderFleet()
        assert fleet.origin == Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
        invader = fleet.invaders[5*5]  # bottom row middle column
        assert invader.rect.center[0] == 512

Nice (not nice) of pygame to return a tuple for center instead of a Vector2. We’ll deal with it. Test fails for now, because the center hasn’t been set.

class InvaderFleet(Flyer):
    def __init__(self):
        self.invaders = [Invader(x//5, x % 5) for x in range(55)]
        self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
        self.reverse = False
        self.update(0, None)

    def update(self, delta_time, _fleets):
        for invader in self.invaders:
            invader.move_relative(self.origin)

class Invader:
    def move_relative(self, origin):
        pos = (origin.x + 64*self.row, origin.y - 64*self.column)
        self.rect.center = pos

If I were to pass in a Vector2, would the center come out as a Vector2? No.

Somehow I have broken the interaction test.

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper_x = 16
        bumper = Bumper(bumper_x)
        invader_column = 5
        invader = Invader(invader_column, 2)
        start = Vector2(64, 512)
        step = 64
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse
        start_x = bumper_x - invader_column*step
        start = Vector2(start_x, 512)
        invader.draw(None, start, step)
        invader.interact_with_bumper(bumper, fleet)
        assert fleet.reverse

The first assert is failing, saying that reverse has been set. I am tiring and need a break. Fix this test and then break?

Ah. We have to initialize our invader better, I think. He doesn’t update on draw any more.

    def test_bumper_invader_collision(self):
        fleet = InvaderFleet()
        bumper_x = 16
        bumper = Bumper(bumper_x)
        invader_column = 5
        invader = Invader(invader_column, 2)
        start = Vector2(64, 512)
        step = 64
        invader.move_relative(fleet.origin)
        invader.interact_with_bumper(bumper, fleet)
        assert not fleet.reverse
        start_x = bumper_x - invader_column*step
        start = Vector2(start_x, 512)
        invader.move_relative(start)
        invader.interact_with_bumper(bumper, fleet)
        assert fleet.reverse

We are green and our changes are harmless. The invaders still draw correctly. Let’s do one more thing. We no longer use the start and step in Invader.draw so let’s remove them.

class InvaderFleet(Flyer):
    def draw(self, screen):
        pos = u.CENTER
        hw = Vector2(100, 200)
        rect = (pos - hw/2,  hw)
        pygame.draw.rect(screen, "blue", rect)
        step = 64
        for invader in self.invaders:
            invader.draw(screen)

class Invader:
    def draw(self, screen):
        if screen:
            pygame.draw.rect(screen, "yellow", self.rect)
            pygame.draw.circle(screen, "red", self.rect.center, 16)

All still good. Commit: steps toward invader motion. Let’s sum up and break before we do any damage.

Summary

We have moved closer to moving the invaders. They now understand a method move_relative(origin), which sets their rectangle center to the proper position relative to the origin point. The origin point is the proposed position for the (0, 0) invader.

In addition, the invaders can determine whether they intersect a Bumper, a new class (thanks, Bruce!) that we’ll use as a boundary on each side of the screen. When an invader hits the Bumper, it will send a message at_edge() to the InvaderFleet, which will then know to turn the invaders in the other direction … once the current move cycle is complete.

We haven’t quite got it all in place. I had expected a bit more this morning, but I’ve been at it for two hours and it’s time for a break. It’s also 0812 hours. I got up early.

It’s a bit ragged right now, but I think the basics are taking shape. I confess that I did patch in a simple move in InvaderFleet update and the red dots do move as one would expect. So I know we’re pretty close.

But I’m tired and further work will be more ragged and likely to break something. I’ll pick it up next time, perhaps this afternoon.

See you then!



  1. @bonder@tilde.zone. I always think of him as W. B. Onder, and I bet I’m just one of many who have irritated him by saying that. Thanks, Bruce!