Python Asteroids+Invaders on GitHub

Before I try to improve the way the Shield adjusts its image and mask, I feel that I need a better understanding of how things work. We’ll use tests to learn and record.

There’s a lot of magic going on in the code that carves bits out of the Shield and its mask:

    def process_shot_collision(self, shot, explosion_mask):
        collider = Collider(self, shot)
        if collider.colliding():
            overlap_mask: Mask = collider.overlap_mask()
            self.update_mask_and_visible_pixels(collider, explosion_mask, overlap_mask, shot)

    def update_mask_and_visible_pixels(self, collider, explosion_mask, overlap_mask, shot):
        self._tasks.remind_me(lambda: self.erase_shot_and_explosion_from_mask(shot, collider.offset(), overlap_mask, explosion_mask))
        self._tasks.remind_me(lambda: self.erase_visible_pixels(overlap_mask, self._mask))

    def erase_shot_and_explosion_from_mask(self, shot, collider_offset, shot_overlap_mask, explosion_mask):
        self._mask.erase(shot_overlap_mask, (0, 0))
        self.erase_explosion_from_mask(collider_offset, explosion_mask, shot)

    def erase_explosion_from_mask(self, collider_offset, explosion_mask, shot):
        expl_rect = explosion_mask.get_rect()
        offset_x = (shot.rect.w - expl_rect.w) // 2
        adjust_image_to_center = collider_offset + Vector2(offset_x, 0)
        self._mask.erase(explosion_mask, adjust_image_to_center)

    def erase_visible_pixels(self, shot_mask, shield_mask):
        rect = shot_mask.get_rect()
        surf = shield_mask.to_surface()
        self._map.blit(surf, rect)

Let me list things that I know, think I know, and wonder about, things that I need to understand and correlate if I’m to have code that makes sense.

  • What are the various rectangles, of the shield, the shot, the collider overlap, the overlap mask, and the explosions?
  • How can we best understand these and relate them to each other?

That’s enough to start with. Let’s dig into the overlap mask. Our call is:

overlap_mask: Mask = collider.overlap_mask()

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

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

The document says:

Returns a Mask, the same size as this mask, containing the overlapping set bits between this mask and other.

The call requires an offset, an x,y pair describing the offset of the “right” argument from the “left”. I think the code above would be more clear if we were to use the centers of the masks, rather than the topleft. Will it work that way? Not if that’s the only change we make: I just tried it.

Consider a big rectangle and a little one, the little one offset a ways down and to the right from the big one’s top left. Clearly big.topleft - little.topleft is not equal to big.center - little.center. Still, it would be “nice” if we could use centers, since in our code we think about the centers of things.

Let’s try some tests. I was going to put them in with the Collider tests but let’s make a new set of tests called testmasking.

After some messing about, and after learning that the file has to be named “test_masking”, I have this first test:


class TestMasking:

    @pytest.fixture
    def make_missile(self):
        surf = Surface((3, 3))
        # ***
        # * *
        # ***
        surf.set_colorkey("black")
        surf.fill("white", surf.get_rect())
        surf.set_at((1,1), "black")
        mask = pygame.mask.from_surface(surf)
        return Thing(mask.get_rect(), mask)

    @pytest.fixture
    def make_target(self):
        surf = Surface((8, 8))
        surf.set_colorkey("black")
        surf.fill("white", surf.get_rect())
        mask = pygame.mask.from_surface(surf)
        return Thing(mask.get_rect(), mask)

    def test_raw_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask = target.mask.overlap_mask(missile.mask, (0, 0))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)

I’m not sure those fixtures are going to help much. I’m trying to learn how to use them well by using them poorly.

So that test has a target consisting of an 8x8 solid mask, and a missile, a 3x3 one solid except for a hole in the middle. I am not sure these are going to tell me everything I want to know, but it’s a start. My plan is to move the missile mask around on the target and read out what I get.

I figure after I do that a bit, I’ll know what I really want to test.

In fact, I want to change the missile right now, to a T shape:

    @pytest.fixture
    def make_missile(self):
        surf = Surface((3, 3))
        # ***
        #  * 
        #  * 
        surf.set_colorkey("black")
        surf.fill("white", surf.get_rect())
        surf.set_at((0, 1), "black")
        surf.set_at((0, 2), "black")
        surf.set_at((2, 1), "black")
        surf.set_at((2, 2), "black")
        mask = pygame.mask.from_surface(surf)
        return Thing(mask.get_rect(), mask)

That should let me try this and get an empty mask back:

    def test_empty_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (8, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        for x in range(rect.w):
            for y in range(rect.h):
                assert overlap_mask.get_at((x, y)) == 0

Now if we move one to the left, we should hit just two bits, (7, 0) and (7, 1). Let’s try that.

I am mistaken, the offsets there need to be 7 and 6: The offset is the top left coordinate, not the step. These tests run:

    def test_empty_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (7, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        for x in range(rect.w):
            for y in range(rect.h):
                assert overlap_mask.get_at((x, y)) == 0

    def test_top_right_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (6, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

I think I understand the offset in the overlap_mask call. But I want my objects to be positioned by their centers. How will that affect what we’ll want our ImageMasher to do?

Let’s test-drive a little method that will erase part of our big mask, where it is touched by the small mask, starting from the centers of the two masks. What even is the center of an 8 x 8 mask?

Note
I don’t get to the erasure part yet. I got bogged down in bit counting, but I have some tests that are leading me to what may be a good place, as you’ll see in the Summary just below.

Let’s check the centers:

    def test_what_are_centers(self, make_missile, make_target):
        missile = make_missile
        assert missile.rect.center == (1, 1)
        target = make_target
        assert target.rect.center == (4, 4)

OK, interesting, it rounds up. Let’s imagine that our missile is centered on the target center and test what bits are set.

After a little while, I have more tests and not as much understanding as I’d like. I’m tired, though, so will report all my tests and tentative conclusions.

Summary

class TestMasking:

    @pytest.fixture
    def make_missile(self):
        surf = Surface((3, 3))
        # ***
        #  *
        #  *
        surf.set_colorkey("black")
        surf.fill("white", surf.get_rect())
        surf.set_at((0, 1), "black")
        surf.set_at((0, 2), "black")
        surf.set_at((2, 1), "black")
        surf.set_at((2, 2), "black")
        mask = pygame.mask.from_surface(surf)
        return Thing(mask.get_rect(), mask)

    @pytest.fixture
    def make_target(self):
        surf = Surface((8, 8))
        surf.set_colorkey("black")
        surf.fill("white", surf.get_rect())
        mask = pygame.mask.from_surface(surf)
        return Thing(mask.get_rect(), mask)

    def test_what_are_centers(self, make_missile, make_target):
        missile = make_missile
        assert missile.rect.center == (1, 1)
        target = make_target
        assert target.rect.center == (4, 4)

    def test_raw_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask = target.mask.overlap_mask(missile.mask, (0, 0))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert rect.w == 8
        assert rect.h == 8

    def test_empty_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (7, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        for x in range(rect.w):
            for y in range(rect.h):
                assert overlap_mask.get_at((x, y)) == 0

    def test_top_right_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (6, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

    def test_center_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (6, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

    def test_mask_rect_does_not_matter(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        missile.rect.center = (20, 20)
        assert missile.rect.topleft == (19, 19)
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, (6, -1))
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

    def test_use_mask_rect_in_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        missile.rect.center = (7, 0)
        assert missile.rect.topleft == (6, -1)
        assert target.rect.topleft == (0, 0)
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, missile.rect.topleft)
        rect = overlap_mask.get_rect()
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

    def test_use_both_rects_in_overlap(self, make_missile, make_target):
        missile = make_missile
        target = make_target
        target.rect.center = (100, 200)
        assert target.rect.topleft == (100-4, 200-4)
        missile.rect.center = (103, 196)
        offset = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
        assert offset == Vector2(6, -1)
        overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, offset)
        rect = overlap_mask.get_rect()
        assert rect == (0, 0, 8, 8)  # always relative
        assert rect.topleft == (0, 0)
        assert overlap_mask.get_at((7, 0)) == 1
        assert overlap_mask.get_at((7, 1)) == 1
        assert overlap_mask.get_at((7, 2)) == 0

I think the main point here is that these tests are leading me to more and more understanding of how the overlap mask works. I think they probably could use improvement, but I expect that after just a few more, I can write a decent ImageMasher or whatever mask-mangling object I need. We’ll see.

The following are just notes, not necessarily coherent, because my thoughts and understanding are not yet coherent.

Almost all my work with these things has been fiddling with the rects, but then I use the mask to get the overlap and check it.

That last test is doing roughly what I think I want in the real thing, setting the rects by their centers and then offsetting by differencing the toplefts. I may be wrong about wanting that.

I have drawn some pictures on graph cards and counted cells to get my numbers. My tentative conclusion is that I can ignore the fact that I’m moving my objects via their center, and just use the topleft values of the rectangles to compute the offsets for the overlap_mask call. If I’m to erase the bits, however, I’ll need the offset again.

I think my next test, when I come back to this, should be to erase some bits.

Then we’ll try positioning a separate pattern, using the centers and whatever else we need from the overlap_mask, and erasing the pattern. That will be amusing.

I think I need more information from the mask routines.

I just noticed that when I get_rect from a mask, I can include center=(x,y). That might come in handy.

I wonder whether I want a little object containing a surface and a mask, with a position. Far from clear to me at this point.

Possibly the thing will be to translate the objects to some origin, overlap them there, do the erasure there, translate them back. I’ll have to think further. Things are not yet clear to me.

This is often the way when using someone else’s library. We don’t have their mental model and thus can’t quite map ours into theirs.

We’ll get somewhere decent. Maybe even somewhere good.

For now, I’m gonna chill out. See you next time!