Python Asteroids+Invaders on GitHub

With the new ability for Sprites to detect collisions, let’s start converting classes. We should be able to do them one at a time.

We have a vague longer-term goal here, which is to get all the classes using the same mechanisms for display, collision, and such, anticipating a notable simplification in how things work. To that end, yesterday we put collision logic into Sprite. The existing Collider, which a number of objects use, uses Sprite collision if both the objects have Sprites, and uses Masker logic built into Collider if not. In the fullness of time, we should be able to collapse some of that code right out of the system.

I’ll spare you details unless they seem interesting. You can follow the commits on GitHub, if what’s in the article isn’t punishment enough.

Current users of Collider include Invader, InvaderPlayer, InvaderSaucer, InvaderShot, and RoadFurniture. Sprite users are Invader (via InvaderGroup) and PlayerShot. The set difference is of course InvaderPlayer, InvaderSaucer, InvaderShot, and RoadFurniture.

These may all have challenges. The Player is probably simplest. The InvaderSaucer is red. With luck, that will not be a problem. And RoadFurniture takes damage, and has a specialized draw method, I believe. I remain hopeful that there will be nothing to write about with these changes.

I’ll start with Saucer, which aside from redness, should be simple.

Saucer goes easily. I just created the new Sprite method, built the sprite, replaced the properties, forgot draw, fixed draw, and it was good. I even shot down the saucer on the first try!

Commit: Saucer uses Sprite.

I’ll try the Player. It goes without difficulty except that I forgot draw again. Commit: Player uses Sprite.

Note
I was mistaken here, because when I did the “set difference” above, I missed out InvaderShot. We quickly discover below that it’s also a candidate for Sprite.

I’m left with RoadFurniture. There are two styles. I knew there was something odd about it. There are the shields, and the bottom line. I’ll do the shields first, as they are actually bitmaps. The bottom line, I think, is constructed with drawing calls.

Murr. Because there are the two styles, the problem is harder. I kind of need to convert them both at once. I’ll try it that way one time and when that fails, devise something better.

Note
Mysteriously, not enough goes wrong, and no reset is required. Skill? Luck? Some of each?

The first part goes OK. I can draw the shields and road furniture with Sprites. The damage needs work, because it needs to update the mask and the image.

I get around that with some hackery that we’ll discuss, and I observe all four shields take damage at the same time. It takes me a bit of discovery to realize that I need to copy the input surface, because BitmapMaker caches them, and I need to set the color key on it because when it takes damage we want the background to show through, not black. So the shield1 class method is a bit special:

class Sprite:
    @classmethod
    def shield(cls):
        surface = BitmapMaker.instance().shield.copy()
        surface.set_colorkey("black")
        return cls((surface,))

A close look tells me that the bottom line has never had its color key set and is in fact damaging to black instead of our blue background, so I fix that. Commit: RoadFurniture uses Sprite.

It comes down to InvaderShot, which will have a few different versions. I think this one may be worth going through in detail.

class InvaderShot(InvadersFlyer):
    def __init__(self, position, maps):
        self.maps = maps
        self.masks = [pygame.mask.from_surface(bitmap) for bitmap in self.maps]
        self._map = maps[0]
        self.map_index = 0
        self._rect = self._map.get_rect()
        self.rect.center = position
        self.count = 0
        self.moves = 0
        self._available = True
        explosion = BitmapMaker.instance().invader_shot_explosion
        self.explosion_mask = pygame.mask.from_surface(explosion)

These are created by the shot controller:

class ShotController(InvadersFlyer):
    def __init__(self):
        self.time_since_firing = 0
        self.shots = [
            InvaderShot(self.off_screen, BitmapMaker.instance().squiggles),
            InvaderShot(self.off_screen, BitmapMaker.instance().rollers),
            InvaderShot(self.off_screen, BitmapMaker.instance().plungers)]
        self.columns = [
            Cycler([0x00, 0x06, 0x00, 0x00, 0x00, 0x03, 0x0A, 0x00, 0x05, 0x02, 0x00, 0x00, 0x0A, 0x08, 0x01, 0x07]),
            Cycler([0x0A, 0x00, 0x05, 0x02, 0x00, 0x00, 0x0A, 0x08, 0x01, 0x07, 0x01, 0x0A, 0x03, 0x06, 0x09])]
        self.shot_index = 0
        self.invader_fleet = None
        self.player_x = 0

We’ll create the surfaces here, and pass them instead of maps to the shot.

class ShotController(InvadersFlyer):
        ...
        self.shots = [
            InvaderShot(self.off_screen, Sprite.squiggles()),
            InvaderShot(self.off_screen, Sprite.rollers()),
            InvaderShot(self.off_screen, Sprite.plungers())]

class Sprite:
    @classmethod
    def squiggles(cls):
        return cls(BitmapMaker.instance().squiggles)

    @classmethod
    def rollers(cls):
        return cls(BitmapMaker.instance().rollers)

    @classmethod
    def plungers(cls):
        return cls(BitmapMaker.instance().plungers)

class InvaderShot(InvadersFlyer):
class InvaderShot(InvadersFlyer):
    def __init__(self, position, sprite):
        self._sprite = sprite
        self.position = position
        self.count = 0
        self.moves = 0
        self._available = True
        explosion = BitmapMaker.instance().invader_shot_explosion
        self.explosion_mask = pygame.mask.from_surface(explosion)

    def update_map(self):
        self._sprite.next_frame()

This plus the new draw makes it work in the game but some tests have broken. At least some of those are due to creating test shots using the BitmapMaker instead of Sprite.

One is checking that the map changes on update. No longer applicable, delete it. The others are straightforward updates, either fixing test constructors of shots or internal assumptions that needed revision. We seem good. Commit: InvaderShot uses Sprite.

Now there are no users of Collider who do not have sprites, so the collider can be simplified:

class Collider:
    def __init__(self, left, right):
        self.left_collider = left._sprite
        self.right_collider = right._sprite

    def colliding(self):
        return self.left_collider.colliding(self.right_collider)

That breaks some of its tests. I think it is now essentially meaningless, but some of its tests might be useful if tested in Sprite’s collision logic. I’ll leave Collider alone for now. I think I can convert the test bitmaps to Sprites and change the tests to test collisions in Sprite. We’ll leave that for a fresh session.

Summary

We have managed to convert all our bitmap-displaying classes to use the new Sprite object, and each class was able to be changed independently, so we could, in principle, have done this over an extended period if we wanted to focus more on features. We have not yet reaped all the benefit from the single approach, which will come when we can remove Collider and Masker and consolidate some operations. We do have most of the bitmap-handling operations centralized, with the exception of the ImageMasher, which handles applying visible damage to surfaces. That should definitely be cleaned up: the way we do it in RoadFurniture is nasty. Did I show you that hackery? I think not. Here it is:

class RoadFurniure(InvadersFlyer):
    def mash_image(self, shot):
        masher = ImageMasher.from_flyers(self, shot)
        new_mask, new_surface = masher.update(self.mask, self.surface)
        self._sprite._masks = (new_mask,)
        self._sprite._surfaces = (new_surface,)

This code rudely plonks new values into the object’s Sprite. Just replacing the Sprite from the surface seems not to work. This needs improvement. The case of RoadFurniture is unique but that’s no excuse for its being nasty and this is nasty.

So, before we can reasonably say that we have squeezed all the juice out of Sprite, we need to deal with this issue, to improve the tests by moving some from Collider to Sprite, and to see if we can get rid of Masker and perhaps even Collider itself.

The code is creeping toward better, but so far the results are intangible. Soon, we’ll be able to remove some code, which is a bit more tangible.

Overall, my take is that this change is worth it, but it is not a big win. It hides complexity from all the bit-mapped classes’ initialization, and combines the surface, mask, and rectangle notions that all those classes supported differently. When we maintain those classes, it’ll be a bit easier. Not a big win, but a lot of little ones.

See you next time!