Python Asteroids+Invaders on GitHub

Bill Wake’s comment suggests more use of the new Masker. Looks useful.

Bill Wake commented on Mastodon:

Came out nice. I’ve lost track of what mask is, but Masker seems like it could be an operation or two on mask. After all, there’s an algebra of masks, where “move” and “erase” (ie “and-not”) are some of the operators.

Now as it happens, we don’t do much with Pygame masks, but we do have at least one other use, in the object that checks for collisions, the Collider:

class Collider:
    def __init__(self, left, right):
        self.left_rect = left.rect
        self.left_mask = left.mask
        self.right_rect = right.rect
        self.right_mask = right.mask

    def colliding(self):
        if self.right_rect and self.right_rect and self.left_mask and self.right_mask:
            return self.rectangles_colliding() and self.masks_colliding()
        else:
            return False

    def rectangles_colliding(self):
        return self.left_rect.colliderect(self.right_rect)

    def masks_colliding(self):
        return self.left_mask.overlap(self.right_mask, self.offset())

    def offset(self):
        offset = Vector2(self.right_rect.topleft) - Vector2(self.left_rect.topleft)
        return offset

    def overlap_mask(self):
        return self.left_mask.overlap_mask(self.right_mask, self.offset())

The method overlap_mask is used only in tests. We should look into that.

We see in Collider, some of the same manipulating of offsets and casting into Vector2 that we had in Shield and ImageMasher, that is now situated in Masker. It seems that Collider might be able to use Masker to its advantage.

I can imagine proceeding in two ways. We could just plug in Masker instances here and use them to check the masks for overlap, adding a method or two to Masker as needed, relying on existing tests to do the job.

But another possibility is to add Masker tests to support collision. I think that’ll be better: things will not be broken as long as they would be if I proceed “top down” from Collider down into Masker.

And something else comes to mind: Masker has a rect as well as the mask, and therefore it can do the whole job of checking for collision. That would mean that Collider’s only job would be to transform its two parameters into Masker instances and ask them whether they are colliding.

Let’s push in that direction and then remember to discuss this possibly profligate use of tiny objects.

Collision Logic in Masker

First, we’ll refresh our minds about Masker:

class Masker:
    def __init__(self, mask, position):
        self.mask = mask
        self.rect = mask.get_rect()
        self.rect.center = position

    @property
    def topleft(self):
        return Vector2(self.rect.topleft)

    def erase(self, masker):
        offset = masker.topleft - self.topleft
        self.mask.erase(masker.mask, offset)

    def get_mask(self):
        return self.mask

It came to me earlier this morning, if you can imagine something earlier than 0445, that a better name for Masker might be PositionedMask or CenteredMask. If we start using it outside ImageMasher, we might want to consider a rename, and a home of its own. It’s inside the ImageMasher file for now.

Begin with a test, for rectangles colliding. I figure we’ll do that, then mask collision, and then we can handle Collider’s needs.

My test_masking tests are over 400 lines now, because they include a number of very simple characterization tests that I did while trying to figure out just what Pygame’s masks are all about. No harm done, I generally only review tests if they break, and I append new ones at the end of the test file, so we’ll just carry on.

I’ll begin by extracting those two block masks, five and three, that I used just twice yesterday, making test fixtures of them. I expect to need them a few more times. I’ll spare you that.

    def test_rectangle_collision(self, three_mask):
        target = Masker(three_mask, (100, 100))
        bullet = Masker(three_mask, (100, 100))
        assert target.rectangles_collide(bullet)

Here I make two Maskers, with 3x3 masks, both centered at (100, 100). And I posit a new method, rectangles_collide. Test fails because no such method. Thus:

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

THat was rather easy, wasn’t it? Let’s do extend the test just to be sure.

    def test_rectangle_collision(self, three_mask):
        target = Masker(three_mask, (100, 100))
        bullet = Masker(three_mask, (100, 100))
        assert target.rectangles_collide(bullet)
        bullet = Masker(three_mask, (102, 100))
        assert target.rectangles_collide(bullet)
        bullet = Masker(three_mask, (103, 100))
        assert not target.rectangles_collide(bullet)

That passes, Three steps and we no longer overlap. We could test other directions but that seems pointless to me.

Let’s do a mask test. My plan is to use my 3x3 solid square as target and a 3x3 plus shape as bullet.

    def test_mask_collision(self, three_mask, plus_mask):
        target = Masker(three_mask, (100, 100))
        bullet = Masker(plus_mask, (100, 100))
        assert target.masks_collide(bullet)

Again we need a method:

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

And the full test runs:

    def test_mask_collision(self, three_mask, plus_mask):
        target = Masker(three_mask, (100, 100))
        bullet = Masker(plus_mask, (100, 100))
        assert target.masks_collide(bullet)
        bullet = Masker(plus_mask, (101, 101))
        assert target.masks_collide(bullet)
        bullet = Masker(plus_mask, (102, 102))
        assert not target.masks_collide(bullet)

I should be committing these marvels. Commit: Masker understands rectangles_collide and masks_collide.

We’re not quite done yet. For completeness we need a colliding method, with tests of course:

    def test_masker_colliding(self, three_mask, plus_mask):
        target = Masker(three_mask, (100, 100))
        bullet = Masker(plus_mask, (100, 100))
        assert target.colliding(bullet)
        bullet = Masker(plus_mask, (101, 101))
        assert target.colliding(bullet)
        bullet = Masker(plus_mask, (102, 102))
        assert not target.colliding(bullet)

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

Green. Commit: Masker understands colliding.

Let’s reflect for a moment.

Reflection

These new methods, rectangles_collide, masks_collide, and colliding, were quite easy to implement. I think that tells us that those ideas fit nicely with the idea of the Masker object. I take that as a sign that we’re on the right track with this object, since it can provide desired capabilities so readily.

Let’s put the new facilities to use in Collider.

Collider uses Masker

First I just add new members for the masks and use them in colliding:

class Collider:
    def __init__(self, left, right):
        self.left_masker = Masker(left.mask, left.position)
        self.right_masker = Masker(right.mask, right.position)
        self.left_rect = left.rect
        self.left_mask = left.mask
        self.right_rect = right.rect
        self.right_mask = right.mask

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

That keeps everything compiling and defers the colliding method to Maskers, but does not remove the old methods. Therefore all the tests continue to run.

But Collider now has a lot of code that we can get rid of … and tests referring to it:

    def rectangles_colliding(self):
        return self.left_rect.colliderect(self.right_rect)

    def masks_colliding(self):
        return self.left_mask.overlap(self.right_mask, self.offset())

    def offset(self):
        offset = Vector2(self.right_rect.topleft) - Vector2(self.left_rect.topleft)
        return offset

    def overlap_mask(self):
        return self.left_mask.overlap_mask(self.right_mask, self.offset())

Let’s first commit this, because it works and we’re using the new Masker feature. Commit: Collider uses Masker. Unused methods and tests need cleanup.

Now, I think I’ll just remove all that unused code above and then sort out the failing tests.

I’m glad I made that choice: there are only two broken tests:

    def test_not_colliding(self, make_target, make_probe):
        target_object = self.make_target_object(make_target)
        probe_object = self.make_probe_object(make_probe)
        collider = Collider(target_object, probe_object)
        assert collider.rectangles_colliding()
        assert not collider.masks_colliding()
        assert not collider.colliding()

    def test_colliding(self, make_target, make_probe):
        target_object = self.make_target_object(make_target)
        probe_object = self.make_probe_object(make_probe)
        probe_object.rect.center = (1, 1)
        collider = Collider(target_object, probe_object)
        assert collider.rectangles_colliding()
        assert collider.masks_colliding()
        assert collider.colliding()

I’ll remove the detailed checks on rectangles and masks and leave the main checks on Collider, which will save us if somehow we were to break it.

    def test_not_colliding(self, make_target, make_probe):
        target_object = self.make_target_object(make_target)
        probe_object = self.make_probe_object(make_probe)
        collider = Collider(target_object, probe_object)
        assert not collider.colliding()

    def test_colliding(self, make_target, make_probe):
        target_object = self.make_target_object(make_target)
        probe_object = self.make_probe_object(make_probe)
        probe_object.rect.center = (1, 1)
        collider = Collider(target_object, probe_object)
        assert collider.colliding()

Commit: remove unused code from Collider, adjust tests accordingly.

There’s now just one tiny thing that I would like to think about:

class Masker:
    def erase(self, masker):
        offset = masker.topleft - self.topleft
        self.mask.erase(masker.mask, offset)

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

Those two duplicated lines are concerning. Why? Well, at base there is just the fact that I’ve built up an intolerance for duplication, because I find that removing duplication generally makes my code better. But specifically, in this case, if for some reason the right way to calculate offset changes, we’d have to discover and change both these methods.

So we extract a tiny method:

    def erase(self, masker):
        offset = self.offset(masker)
        self.mask.erase(masker.mask, offset)

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

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

And, of course, we inline those calls because now they are simple enough:

    def erase(self, masker):
        self.mask.erase(masker.mask, self.offset(masker))

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

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

That pleases me. Commit: remove duplication, inline temps.

Let’s sum up.

Summary

We have added collision detection to our Masker, and it does the efficient thing of first checking rectangle collision and only if those are colliding, checking mask collision. And we use it in Collider, reducing Collider down to a constructor and a single method.

That constructor, however, has an important responsibility. The user of Collider wants to know whether two Flyer instances are colliding, such as a Shield and an InvaderShot. Collider speaks in those terms, and translates those terms into two Masker objects, which it knows how to create, and defers the decision to them.

We could, if we wished, push the translation of Flyer to Masker down into Masker. Alternatively, we could put a method or property on our Flyers, as_masker to return a Masker. But, today at least, I think it is best where it stands.

Collider serves its users by dealing with Flyers. Masker just knows about rectangles and masks. Collider knows a bit about Flyers … just enough to get their mask and position. I think that’s nearly good.

There is something that I can just barely hear whispering “me, me”. Since a Masker is about a rectangle, a mask and a position, could it make sense for our Flyers to have a Masker as a member, keeping track of those matters for the Flyer? All our InvadersFlyers do have a rect and mask and position … so there is an interesting possibility there.

That might be worth looking into.

Are you feeling resistance to that idea? So am I. It seems that it would require us to change all the InvadersFlyer types to use this new scheme, and that would be bad. And to what benefit? Well, it’s hard to say but it might let us encapsulate a bit more detail in Masker, might simplify the InvadersFlyers and make them more consistent. And, honestly, we could almost certainly change them one at a time, with no big need to do the whole batch at once.

It might pay off. Might. In this small program, no real payoff other than the learning. In a larger program that we were keeping alive for years, changes like this can pay off by reducing the difficulty of future changes, and by keeping the code alive.

I’m not saying we’ll do it. I’m not saying we won’t. I’m not saying we should do it, nor saying that we shouldn’t. I’m just trying to stay open to ideas, and trying to try the ones that seem fruitful.

But now to the question I promised to address: Creating those tiny objects.

Tiny objects in my hand

The game checks collisions a lot. Currently, for every pair of objects considered for collision, we create two Colliders, one for A colliding with B and one for B colliding with A. Each Collider then creates two Maskers. We ask if there is a collision and throw them all away. Is creating and destroying all those little objects a problem?

My general rule is to say “not likely to be a problem”, and move on. If some kind of performance issue were to arise, we might find that object creation is part of the problem, and we might not. In a tight modern video game, performance comes down to keeping things close together in memory (or so I’m told) and they use the Entity Component System pattern to deal with that. In a game like this one, that seems to be entirely unnecessary.

Modern garbage collectors are quite advanced and in general we can trust them to do the right thing. So my practice is not to worry about creating and destroying lots of tiny objects. YMMV, of course, and you do you.

Me, for now, I’m happy with how this turned out. We added a few one-line methods to Masker, and removed more methods and lines from Collider. Collider is almost too trivial now and Masker seems coherent.

For an early morning session, we have done well. See you next time!