Python Asteroids+Invaders on GitHub

Let’s see about how to detect collision between the invaders and the player shots. I think we want to do actual bit overlap.

In principle, we could detect collision by doing rectangle intersections. We’d have to use adjusted rectangles, not the ones that cover the shapes, because the aliens have at least two blank columns on each side and the shot is blank on top: it’s only four pixels high in a frame of 8. So what I think we need to do is first check the rectangles, because that’s fast, and then check to see if any bits in the shot are intersecting bits in the invader.

I have found some code for doing that and can probably find it again. We’ll test-drive this one for sure.

I get to here …

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.set_position(u.CENTER)
        shot = PlayerShot(Vector2(0, 0))

Now I wonder what to test. I want two methods, somewhere, one determining if the rectangles overlap, and one determining if there are visible bits in common. How will those be used? Something like this:

class Invader:
	def interact_with_playershot(self, shot, fleets):
		if self.rectangles_overlap(self.rect, shot.rect):
			if self.bits_overlap(self.mask, shot.mask):
				self.explode()

Does pygame know how to do rectangle overlapping? I should think so. Ah, yes, colliderect. Let’s try that in our test.

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.set_position(u.CENTER)
        shot = PlayerShot(Vector2(0, 0))
        assert not invader.rect.colliderect(shot.rect)
        shot.rect.center = u.CENTER
        assert invader.rect.colliderect(shot.rect)

This is passing. But this is all rather inconsistent. The shot updates position as a vector and only sets the rectangle center right before drawing. The invader uses a set_position function that does update the center.

I believe that what we should do is have them both keeping track of their “position” as the center of their rect, and provide getters and setters as needed, using the property notion.

We’ll change this:

class PlayerShot(InvadersFlyer):
    def __init__(self, position=u.CENTER):
        offset = Vector2(2, -8*4)
        self.position = position + offset
        self.velocity = Vector2(0, -4*4)
        maker = BitmapMaker.instance()
        self.bits = maker.player_shot
        self.rect = self.bits.get_rect()

To this:

class PlayerShot(InvadersFlyer):
    def __init__(self, position=u.CENTER):
        offset = Vector2(2, -8*4)
        self.velocity = Vector2(0, -4*4)
        maker = BitmapMaker.instance()
        self.bits = maker.player_shot
        self.rect = self.bits.get_rect()
        self.position = position + offset

    @property
    def position(self):
        return Vector2(self.rect.center)

    @position.setter
    def position(self, vector):
        self.rect.center = vector

This has the property that you can’t just set position.y, which one of my tests did. I hammered the test.

Now let’s change the Invader similarly. From:

class Invader:
    def __init__(self, column, row, bitmaps):
        self.bitmaps = bitmaps
        self.column = column
        self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
        self.rect = pygame.Rect(0, 0, 64, 32)
        self.image = 0

    @property
    def position(self):
        return Vector2(self.rect.center)

    def set_position(self, origin):
        self.image = 1 if self.image == 0 else 0
        self.rect.center = origin + self.relative_position

I change to this:

class Invader:
    def __init__(self, column, row, bitmaps):
        self.bitmaps = bitmaps
        self.column = column
        self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
        self.rect = pygame.Rect(0, 0, 64, 32)
        self.image = 0

    @property
    def position(self):
        return Vector2(self.rect.center)

    @position.setter
    def position(self, vector):
        self.image = 1 if self.image == 0 else 0
        self.rect.center = vector + self.relative_position

That took me a lot longer than I had intended. And I’m not entirely loving changing the image on each set, though it works fine. We’ll let it be for now.

I was working on this test:

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        assert not invader.rect.colliderect(shot.rect)
        shot.rect.center = u.CENTER
        assert invader.rect.colliderect(shot.rect)

It is running correctly, but I should change it to use position on both sides:

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        assert not invader.rect.colliderect(shot.rect)
        shot.position = u.CENTER
        assert invader.rect.colliderect(shot.rect)

Still good. Now let’s posit a method on invader to start it deciding about collisions:

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        assert not invader.rect.colliderect(shot.rect)
        assert not invader.rectangles_overlap(shot)
        shot.position = u.CENTER
        assert invader.rect.colliderect(shot.rect)
        assert invader.rectangles_overlap(shot)

This is of course easy:

class Invader:
    def rectangles_overlap(self, shot):
        return self.rect.colliderect(shot.rect)

Now the bitmaps. We use masks for this, so let’s give shot and invader a mask. Ah. The mask for the invader needs to vary depending on which image is in use.

class Invader:
    def __init__(self, column, row, bitmaps):
        self.bitmaps = bitmaps
        self.masks = [pygame.mask.from_surface(bitmap) for bitmap in self.bitmaps]
        self.column = column
        self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
        self.rect = pygame.Rect(0, 0, 64, 32)
        self.image = 0

    @property
    def mask(self):
        return self.masks[self.image]

I think that’s how you do that. We’ll see.

class PlayerShot(InvadersFlyer):
    def __init__(self, position=u.CENTER):
        offset = Vector2(2, -8*4)
        self.velocity = Vector2(0, -4*4)
        maker = BitmapMaker.instance()
        self.bits = maker.player_shot
        self.mask = pygame.mask.from_surface(self.bits)
        self.rect = self.bits.get_rect()
        self.position = position + offset

Now we have to use the mask. I think we use the overlap function, which returns a point in the overlap where both masks have a pixel. I need a new test.

I quickly learn that I don’t understand something. I have this:

    def test_shot_invader_mask_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        offset = invader.position - shot.position
        assert not invader.mask.overlap(shot.mask, offset)
        shot.position = u.CENTER
        offset = invader.position - shot.position
        assert invader.mask.overlap(shot.mask, offset)

I think I want to see the masks that I got.

A delay happens here …

I’m taking a break. Back later today or tomorrow. But I have a mask working, sort of.


Tuesday AM

The above was Monday afternoon, distracted, over the period from 1330 to 1630, but lots of offline chatter and stuff going on as well as trying to program. My afternoon sessions are often like that.

But I do have a test running. Let’s talk about it: it reflects something that I often do but don’t often write about.

We are implementing a two-phase collision check, first checking for overlap between the rectangles of the objects in question, and if they overlap, then checking the two bitmaps to see whether any bits have collided. This is needed, if for no other reason, because the invaders have at least two empty columns on each side.

The rectangle check was easy. Here’s my current test for the bitmap overlap:

    def test_shot_invader_mask_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        offset = Vector2(shot.rect.topleft) - Vector2(invader.rect.topleft)
        assert not invader.mask.overlap(shot.mask, offset)
        shot.position = u.CENTER
        offset = Vector2(shot.rect.topleft) - Vector2(invader.rect.topleft)
        assert invader.mask.overlap(shot.mask, offset)

So here’s the deal as I understand it. The mask.overlap method takes two parameters, the second mask to be checked against the receiver, and an offset. The reason that’s needed is that, unlike a surface, which has a rectangle, which can have a position, it appears that a mask is always just a rectangle of bits from (0, 0), to (n, m). You can’t move them, set their centers, or any of that.

Pygame does have a class Sprite, which holds an image, has a rectangle, and has a method collide_bits or something like that. We might play with that a bit later: it could be that it provides a better way of doing things.

As I was saying before I so rudely interrupted myself, masks are always based at (0, 0). But when we think of two things colliding, like our tiny shot and huge invader, the shot might overlap the invader anywhere. In the test above, it overlaps right at the center, since I’ve given them both the same position.

If we used an offset of (0, 0), we’d have the shot at the top left of the invader’s rectangle, which is always empty, and the shot would always show no overlap. So we need to provide an offset that reflects that the shot is at the center of the invader. That is the difference of the two objects’ rectangle top left corners. (Not, say, the distance between centers, which is zero.)

About this test

I wanted to talk in general terms about this test. Often, when we do not know how some feature of our language or library works, we write a little code to find out. In the olden days, we might write a new small program that we bash on until it prints what we want to see, or we might even run it under debug to look around.

In these modern times, I find it better to write a small test to find out how the thing works. I might still print within that test sometimes, especially if I’m confused. I might even debug the test to look around, though I do that very seldom.

If the test is just to get information: what happens when you say range(1,3), does it include the three or not, I stop when I have an assertion that shows what I wanted to know. I try to make the test clear in case I ever have to look at it again but mostly, I’ve got the information and mostly I won’t forget it.

If the test is more intended to help me figure out how best to do something, I’ll edit it repeatedly until it reflects how I want to do things. In the case of these collisions, I’ve already used the rectangle test to impose a method on Invader, rectangles_overlap. That, of course, is the test-driven part of TDD. The test drives out a method.

But often, I just use the test to get information and then move on. I find it useful to do that discovery with tests, and it costs essentially nothing to keep the test. I don’t often look at them later, but they’re there if I need them.

I think I have it sussed out here. I’m not certain yet, and so I want more testing. First, though, I want another method, on Invader I guess, to give me that offset.

I’ll replicate that test using the new method, because I want to preserve the learning in the test we just looked at.

    def test_shot_invader_mask_offset(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        offset = invader.mask_offset(shot)
        assert not invader.mask.overlap(shot.mask, offset)
        shot.position = u.CENTER
        assert invader.mask.overlap(shot.mask, invader.mask_offset(shot))

That drives out this little method:

    def mask_offset(self, invaders_flyer):
        return Vector2(invaders_flyer.rect.topleft) - Vector2(self.rect.topleft)

But what we really want is a method on Invader that simply answers whether the objects’ masks are overlapping.

We have invader.rectangles_overlap already, driven out yesterday. Let’s do masks_overlap.

Duplicate the test again, change it to this:

    def test_shot_invader_masks_overlap(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        assert not invader.masks_overlap(shot)
        shot.position = u.CENTER
        assert invader.masks_overlap(shot)

Now we need the masks_overlap method.

class Invader:
    def masks_overlap(self, invaders_flyer):
        return self.mask.overlap(invaders_flyer.mask, self.mask_offset(invaders_flyer))

Test is green. Let’s commit, I think we’re on a good enough path. Commit: Invader can check rectangle overlap and mask overlap with another object e.g. PlayerShot.

We’re not really done yet. I’d like to check collision between shot and invader more thoroughly. Since the invader has blank space on each side, we should be able to show a few tests where the rectangles overlap and the masks do not. And we’d like to drive out a colliding method as well. We’ll do that in one go … I think.

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader_width = invader.rect.width
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        shot.position = u.CENTER - Vector2(invader_width/2, 0)
        assert invader.rectangles_overlap(shot)
        assert not invader.masks_overlap(shot)
        assert not invader.colliding(shot)

The first two asserts pass, the last one doesn’t because there’s no such method.

class Invader:
    def colliding(self, invaders_flyer):
        return self.rectangles_overlap(invaders_flyer) and self.masks_overlap(invaders_flyer)

Green. Extend the test a bit.

    def test_shot_invader_collision(self):
        maker = BitmapMaker.instance()
        maps = maker.invaders
        invader = Invader(0, 0, maps)
        invader_width = invader.rect.width
        invader.position = u.CENTER
        shot = PlayerShot(Vector2(0, 0))
        shot.position = u.CENTER - Vector2(invader_width/2, 0)
        assert invader.rectangles_overlap(shot)
        assert not invader.masks_overlap(shot)
        assert not invader.colliding(shot)
        shot.position = shot.position + Vector2(4, 0)
        assert not invader.colliding(shot)
        shot.position = shot.position + Vector2(4, 0)
        assert invader.colliding(shot)

Move it far enough in, and it collides. I think this is actually working.

Commit: Invader colliding method.

I am ready to shoot down invaders. We’ll have to forward the interaction from the fleet to the group and from there to the individual invaders, since they are not individual flyers.

class InvaderFleet(InvadersFlyer):
    def interact_with_playershot(self, shot, fleets):
        self.invader_group.interact_with_playershot(shot)

class InvaderGroup:
    def interact_with_playershot(self, shot):
        for invader in self.invaders.copy():
            invader.interact_with_playershot(shot, self)

class Invader:
    def interact_with_playershot(self, shot, group):
        if self.colliding(shot):
            group.kill(self)

I copied the self.invaders because we’re going to remove invaders from the group and removing from a list being iterated is Big Trouble. I think this might work and I’ll test it in the game.

This actually works. I gotta show you this.

shots destroying alien invaders

Commit: shots kill invaders, do not stop at one. killing last invader crashes game.

This is a good stopping point, so let’s sum up.

Summary

Overall, the flow of this feature went well. We test-drove almost everything, except that last bit where I put in the interactions. We progressed from checking the rectangles for collision, to checking the masks, to checking both.

I did bog down trying to suss out how the masks work, but it wasn’t awful and I had a running test before I gave up for the afternoon. From there it was just inch by inch progress, moving from ad hoc calculations to a method for computing the offset, to a method for checking masks for collision, and so on.

There is a bit of an issue that troubles me. We have all the invaders hidden in a group, hidden in a fleet. The reason for that was to avoid interacting 55 invaders who can never affect each other. But what about the shot? The shot only deals with interact_with_invader_fleet. We do not know the order of calling, so we do not know whether the fleet could possibly know whether the shot has hit something and should therefore be terminated.

I think what we need to do might be something like this:

  1. Make Invader a subclass of InvadersFlyer;
  2. Keep them in the group in the fleet;
  3. When they collide with the shot, send them interact_with_invader;
  4. Have the shot ask about the collision, get the “yes”, and remove itself.

We’re really only doing the InvaderFleet and InvaderGroup as a preemptive step for efficiency. In principle, we could let all the Invaders live independently in the Fleets instance, although I’m not sure how we’d deal with the incremental stepping, which is also a reason for the InvaderFleet / Group to exist.

There’s a bump in the design here. Maybe we’ll live with it. Maybe we’ll hack something in. For example, the invader knows it has been hit. It could just send a die message to the shot, skip over all the interact rigmarole. Maybe that’s best.

Oh! If the shot set “should_die” to False in begin_interactions, and we sent die to it during the interactions, setting the flag to True, the shot could check the flag in end_interactions and remove itself. Fairly neat and in the spirit of things. We might do that.

We’ll think about it. For now, I am a pinball wizard: I can kill as many as five aliens with one shot!

See you next time!