Python Asteroids+Invaders on GitHub

With a couple of classes using the Sprite, it seems like time to see if we can put enough useful behavior on it to make it more desirable to move to it.

The behavior I have in mind is detecting collisions. We have all the information we need for sprite-based moving objects: the rectangles, which must overlap, the masks, which must have at least one bit in common, and the location (rectangle center) so that we can get them lined up properly.

In the Invaders code there are at least two ways of detecting collisions, and I suspect there are more. That is understandable in a system that evolves, but it isn’t necessarily desirable, since once a few classes roll their own, rolling your own becomes a thing and the code gets more and more messy. Let’s see what colliding we do today.

The closest to “right”, I think, is the Collider / Masker implementation:

class Invader:
    def colliding(self, invaders_flyer):
        collider = Collider(self, invaders_flyer)
        return collider.colliding()

class Collider:
    def __init__(self, left, right):
        self.left_masker = Masker(left.mask, left.position)
        self.right_masker = Masker(right.mask, right.position)

    def colliding(self):
        return self.left_masker.colliding(self.right_masker)

class Masker:
    def __init__(self, mask, position):
        self.mask = mask.copy()  # don't mutate your input
        self.rect = mask.get_rect()
        self.rect.center = position

    def colliding(self, masker):
        return self.rectangles_collide(masker) and self.masks_collide(masker)

    def rectangles_collide(self, masker):
        return self.rect.colliderect(masker.rect)

    def masks_collide(self, masker):
        return self.mask.overlap(masker.mask, self.offset(masker))

    def offset(self, masker):
        return masker.topleft - self.topleft

The Masker is also used by ImageMasher, an object used to remove chunks from bitmaps due to explosions.

Our Sprite has the mask and the rectangle, as does the Masker. The main difference seems to be that the Masker is given the object’s position and computes a suitable rectangle from the mask and that position.

Observation

We can already see the various little differences that have crept into the code. (Actually, they didn’t creep in. YT put them in, one at a time, bit by bit. At the time, it seemed reasonable. Now, we begin to know better. It may take some unwinding to sort this out.

I think the plan should be that Sprite is the way of the future. So we’ll add collision logic to the Sprite and then see how to deal with the duplication. We may be able to deal with it now, but maybe we’ll let the old way die out as objects move over to the Sprite. I prefer to make no commitment at this time.

So let’s do is_colliding on the Sprite. (And I’m inclined to rename those other colliding methods to is_colliding as well.)

We have some collision tests, in fact a raft of them, on Masker. In fact, the TestMasking tests are over 400 lines long!

I truly do not want to do that over again.

The Collider tests are smaller. They might suffice if duplicated for Sprite.

Ah. What if we did this? What if we were to change Collider so that it used the Sprite to check for collision, instead of its current Maskers. Then existing tests would test the Sprite’s collision logic.

I’ll try that. This will start out as a Spike. I’ll change this:

class Collider:
    def __init__(self, left, right):
        self.left_masker = Masker(left.mask, left.position)
        self.right_masker = Masker(right.mask, right.position)

    def colliding(self):
        return self.left_masker.colliding(self.right_masker)

To this:

class Collider:
    def __init__(self, left, right):
        try:
            self.left_masker = left._sprite
            self.right_masker = right._sprite
        except AttributeError:
            self.left_masker = Masker(left.mask, left.position)
            self.right_masker = Masker(right.mask, right.position)

If they both have sprites, we’ll use them. I get one test failing, saying that Sprite does not have colliding. Just what we need, let’s implement it. I basically copy the colliding code from Masker:

class Sprite:
    def colliding(self, other):
        return self.rectangles_collide(other) and self.masks_collide(other)

    def rectangles_collide(self, other):
        return self.rectangle.colliderect(other.rectangle)

    def masks_collide(self, other):
        return self.mask.overlap(other.mask, self.offset(other))

    def offset(self, other):
        return Vector2(other.rectangle.topleft) - Vector2(self.rectangle.topleft)

Tests and game run. Commit: Sprite implements colliding.

Now how about making all the collisions use Sprite? If we changed collider to create Sprites if it doesn’t find them, everything should still work. I’m not sure we can do that. No, let’s just leave it this way. I should have given this commit a better message. Let’s do that: Collider uses Sprites for collision if both objects have _sprite.

I think we’ll pause here, as it is Sunday and doubtless breakfast will arrive soon.

Summary

Making Sprite collision work was easy and causing the test to use sprites if they exist allowed existing tests to cover the situation. I do not think the tests are very robust but I’m satisfied for now.

There are at least two ways we might proceed:

  • In Collider, create dummy Sprites for all participating Collider users. This may be a bit tricky, because the Sprite is built from a collection of Surfaces, and different objects manage their surfaces differently. We might be able to create a different Sprite constructor that starts from a mask.

  • Simply wait until more and more objects implement the Sprite. As they do, Collider will automatically use the Sprite logic, until one day that’s the only way it happens. Then we can unwind Collider … or not, just leaving it as it is. We’re not afraid to create and destroy a tiny object like that one: we do it now.

By the time we get there, things will be more clear. For now, we are a step along the path to making the Sprite more useful and perhaps eliminating the Masker altogether someday.. I think that will turn out to be a good thing. And if not, we’ll improve it again.

I’d like to normalize the name to is_colliding, but decided to hold off on that until things feel more stable.

See you next time!