Python Asteroids+Invaders on GitHub

Let’s clean up the shields code and improve the damage. I’ve found a missing piece, the explosion part.

We have working code that erases the bits where the invader shot hits the shield. It’s not good code: we’ll make it better. And it doesn’t make enough of a hole in the shield. Fortunately, I have found an explosion that should be just the thing.

For the shield damage to work properly, we want each shot, when it hits the shield, to damage the shield. For that to work, we need to erase some pixels from the shield. We also need to erase those pixels from the shield’s mask, so that our collision logic, which checks for actual bit overlap, will work.

Yesterday, I had made a simple but fundamental mistake. During the interaction between the invader shot and the shield, I erased the bits from the shield. Then, when I erased them from the shield mask, the shots, instead of stopping, fell right on through the shield, tearing a hole right through. It took me a while to figure out what was happening, trying various experiments to see what was wrong, leaving the code in a bit of a mess.

The bug? Ah. I was changing the shield mask immediately. Since interactions take place in two phases, shield vs shot and shot vs shield, when the shot interaction ran, I had already changed the mask. So the shot thought it had not collided and went on to move. Next time around, shot lower down, the process would repeat. So the mask had already moved out of the way of the shot, and it just kept tearing through.

Because of all the experimentation, I’ve left the code is less than pristine form. Let’s review and clean it up before we take the next small step.

class Shield(InvadersFlyer):
    def __init__(self, position):
        maker = BitmapMaker.instance()
        self.map = maker.shield.copy()
        self.map.set_colorkey((0, 0, 0))
        self._mask = pygame.mask.from_surface(self.map)
        self._mask_copy = self.mask.copy()
        self._damage = self._mask.copy()
        self._rect = self.map.get_rect()
        self._rect.center = position

    def begin_interactions(self, fleets):
        self._mask = self._mask_copy.copy()

    def interact_with_invadershot(self, shot, fleets):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self._damage.erase(mask, (0, 0))
            rect = mask.get_rect()
            for x in range(88):
                for y in range(64):
                    bit = mask.get_at((x, y))
                    if bit:
                        self._mask_copy.set_at((x, y), 0)
                        self.map.set_at((x, y), (0, 0, 0))
                    else:
                        pass

I think no one is using the _damage mask for anything now. Right. Remove those lines. Commit: remove unused member.

Now I think we can use erase instead of that set loop, in _mask_copy. I did the loop as part of trying to figure out my defect, thinking I was encountering something odd in the pygame mask logic.

Working on the next step, I discover something interesting in this code:

    def __init__(self, position):
        maker = BitmapMaker.instance()
        self.map = maker.shield.copy()
        self.map.set_colorkey((0, 0, 0))
        self._mask = pygame.mask.from_surface(self.map)
        self._mask_copy = self.mask.copy()
        self._rect = self.map.get_rect()
        self._rect.center = position

With that set_colorkey in there, the mask turns out to be a plain rectangle. That makes shots that would hit the slanted part of the shield get stopped before they get there. Removing the set_colorkey makes the mask have the right shape. Weird. That set was there as part of my chasing my timing bug, so it can go.

class Shield(InvadersFlyer):
    def __init__(self, position):
        maker = BitmapMaker.instance()
        self.map = maker.shield.copy()
        self._mask = pygame.mask.from_surface(self.map)
        self._mask_copy = self.mask.copy()
        self._rect = self.map.get_rect()
        self._rect.center = position

Truly odd. Pygame is truly odd. We live and learn. I think I’ll go belt and suspenders here:

class Shield(InvadersFlyer):
    def __init__(self, position):
        map = BitmapMaker.instance().shield
        self.map = map.copy()
        self._mask = pygame.mask.from_surface(map)
        self._mask_copy = self.mask.copy()
        self._rect = self.map.get_rect()
        self._rect.center = position

I am inclined to rename map to _map but … oh heck, it’s the right thing to do.

OK, I was working on using erase instead of set_at in the loop. This works:

    def interact_with_invadershot(self, shot, fleets):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self._mask_copy.erase(mask, (0, 0))  # <===
            rect = mask.get_rect()
            for x in range(88):
                for y in range(64):
                    bit = mask.get_at((x, y))
                    if bit:
                        self._map.set_at((x, y), (0, 0, 0))
                    else:
                        pass

I should be committing but I am not confident. I don’t really want to try to test bitmap contents, so I’m relying on watching the game.

I think I can do the on-screen shield erasure with a blit. After some fiddling and changing colors to be sure I’m getting transparent pixels, not black, this actually works:

    def interact_with_invadershot(self, shot, fleets):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self._mask_copy.erase(mask, (0, 0))
            rect = mask.get_rect()
            surf = self._mask_copy.to_surface()
            self._map.blit(surf, rect)

I’m ready to commit this. Commit: improving handling of shield and mask.

But there’s more. We copy the mask copy to the mask in begin_interactions. It would be better to do it in end_interactions. That’s easy and works. I think we make too many copies. Let’s review the code looking for unnecessary calls to copy.

class Shield(InvadersFlyer):
    def __init__(self, position):
        map = BitmapMaker.instance().shield
        self._map = map.copy()
        self._map.set_colorkey("black")
        self._mask = pygame.mask.from_surface(map)
        self._mask_copy = self.mask.copy()
        self._rect = self._map.get_rect()
        self._rect.center = position

    def end_interactions(self, fleets):
        self._mask = self._mask_copy.copy()
        pass

    def interact_with_invadershot(self, shot, fleets):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self._mask_copy.erase(mask, (0, 0))
            rect = mask.get_rect()
            surf = self._mask_copy.to_surface()
            self._map.blit(surf, rect)

Well, we really only need to copy _mask_copy into _mask if we have updated it. Better safe than sorry, I think. We’ll leave this.

Reflection

We seem to be at a pause, let’s reflect. We’ve cleaned up some messy code, replacing a loop over bits with an erase and a blit, which seem to work just fine. IUt might be desirable to write a test that verifies what those operations do. Let me create another skipped test as a reminder to work on that maybe sometime in the indefinite future. It would be valuable but it’s the sort of thing one does when real work seems out of reach.

    @pytest.mark.skip(reason="needs work")
    def test_mask_updates_after_shield_hit(self):
        pass

    @pytest.mark.skip(reason="good learning experience")
    def test_erase_and_blit_to_show_how_they_work_in_shield(self):
        pass

It will be interesting to see if these skips, which do show up when the tests run, induce me to write the tests one of these days.

I’ve been a bit slow this morning. The experience yesterday left me uncertain about all the bit operations, probably because I thought they weren’t doing what I expected, when the real problem was elsewhere. But I think we have a solid result.

Let’s check the stories.

  1. Get one shield on the screen;
  2. Get all four on the screen;
  3. Shots ignore shields;
  4. InvaderShots die upon hitting shields;
  5. PlayerShots die upon hitting shields;
  6. InvaderShots do simple damage;
  7. PlayerShots do simple damage;
  8. InvaderShots do fancy damage;
  9. PlayerShots do fancy damage;

Right. We could do the simple damage for player shots next, or the fancy damage for the invader shots.

Let’s do the fancy while I am still feeling semi-intelligent,

I found an explosion for the invader shot in the original source:

; Alien shot exploding
AShotExplo:      
; .*.*..*.
; *.*.*...
; .*****.*
; ******..
; .****.*.
; *.*..*..
1CDC: 4A 15 BE 3F 5E 25   

I’m pretty sure we haven’t imported that one. Make it so:

class BitmapMaker:
	...
        invader_shot_explosion = (0x4A, 0x15, 0xBE, 0x3F, 0x5E, 0x25)
    ...
        self.invader_shot_explosion = self.make_and_scale_surface(invader_shot_explosion, scale, (6, 8))
    ...

We might use that somewhere else, but I think it’s clear that the shield needs to just know this thing, so I’m going to fetch it in Shield.

    def __init__(self, position):
        map = BitmapMaker.instance().shield
        explo = BitmapMaker.instance().invader_shot_explosion
        self._explosion_mask = pygame.mask.from_surface(explo)
        self._map = map.copy()
        self._map.set_colorkey("black")
        self._mask = pygame.mask.from_surface(map)
        self._mask_copy = self.mask.copy()
        self._rect = self._map.get_rect()
        self._rect.center = position

I think I just need the mask. And I want to erase it right where I also erase the shot:

    def interact_with_invadershot(self, shot, fleets):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self._mask_copy.erase(mask, (0, 0))
            rect = mask.get_rect()
            self._mask_copy.erase(self._explosion_mask, collider.offset())
            surf = self._mask_copy.to_surface()
            self._map.blit(surf, rect)

Turns out that I needed the collider’s offset, because we need to know where in the shield we hit and the masks and such don’t tell us. So that works very nicely:

damage to shield looks good

Commit: add invader shot explosion bitmap to shield damage.

Reflection / Summary

I am pleased. The shield damage looks pretty decent. I wonder if it would look even better if the invader shot were to visibly explode, then leave the hole. Let’s add a couple of stories about that:

  1. Get one shield on the screen;
  2. Get all four on the screen;
  3. Shots ignore shields;
  4. InvaderShots die upon hitting shields;
  5. PlayerShots die upon hitting shields;
  6. InvaderShots do simple damage;
  7. PlayerShots do simple damage;
  8. InvaderShots do fancy damage;
  9. PlayerShots do fancy damage;
  10. Display invader shot explosion at shield?
  11. If explosion doesn’t look great, flash it?
  12. Should there be explosions when shot hits shot?

Since the shield is mostly solid white, I was picturing the explosion and thinking it might not look great just to light it up, so that flashing it on and off might show up better. We’ll find out when we do it. And a note to self about shots hitting shots. Right now I think they just vanish.

It remains to replicate what we’ve done for invader shots using player shots: yes, if you shoot your own shield you can make a hole in it. That will probably create some duplication, and we’ll probably want to get rid of it. All in good time.

For now, I think this has gone pretty smoothly, after a confusing day yesterday. We’ll call it a wrap for the morning. See you next time!