Python Asteroids+Invaders on GitHub

Let’s see if we can add lightness to our reminder facility.

When last we met, I had the idea of changing the reminders container in Fleets to a list rather than a dictionary, ignoring the sender object, which we really only use as a key. Let’s try it.

    def begin_interactions(self):
        self.reminders = []
        for flyer in self.all_objects:
            flyer.begin_interactions(self)

    def end_interactions(self):
        for flyer in self.all_objects:
            self.execute_reminders(flyer)
            flyer.end_interactions(self)

    def remind_me(self, sender, reminder: Callable, *args):
        reminder = [reminder, args]
        self.reminders.append(reminder)

    def execute_reminders(self, sender):
        for reminder in self.reminders:
            func = reminder[0]
            args = reminder[1]
            func(*args)
        self.reminders = []

My tests all continue to run. Let’s make sure we have enough that do more than one reminder.

    def test_two_remindables(self):
        o1 = Remindable()
        o2 = Remindable()
        fleets = Fleets()
        fleets. append(o1)
        fleets. append(o2)
        fleets.append(Remindable())
        fleets.begin_interactions()
        fleets.perform_interactions()
        fleets.end_interactions()
        assert o1.compared
        assert o2.compared

I think that’s sufficient. Now we can change the signature of remind_me from this:

    def remind_me(self, sender, reminder: Callable, *args):
        reminder = [reminder, args]
        self.reminders.append(reminder)

To this:

    def remind_me(self, reminder: Callable, *args):
        reminder = [reminder, args]
        self.reminders.append(reminder)

And similarly, change this:

    def end_interactions(self):
        for flyer in self.all_objects:
            self.execute_reminders(flyer)
            flyer.end_interactions(self)

    def execute_reminders(self, sender):
        for reminder in self.reminders:
            func = reminder[0]
            args = reminder[1]
            func(*args)
        self.reminders = []

To this:

    def end_interactions(self):
        for flyer in self.all_objects:
            self._execute_reminders()
            flyer.end_interactions(self)

    def _execute_reminders(self):
        for reminder in self.reminders:
            func = reminder[0]
            args = reminder[1]
            func(*args)
        self.reminders = []

That’s better. Commit: convert reminders to simple list.

Should we make a little object for our list? Let’s do.

class _Reminder:
    def __init__(self, func, args):
        self.func = func
        self.args = args

    def execute(self):
        self.func(*self.args)

class Fleets:
    def remind_me(self, reminder: Callable, *args):
        reminder = _Reminder(reminder, args)
        self.reminders.append(reminder)

    def _execute_reminders(self):
        for reminder in self.reminders:
            reminder.execute()
        self.reminders = []

Nice. Simplifies, adds lightness. Commit: Use “private” class _Reminder.

Game works as advertised. Now, of course, I want to use my new thing but we really don’t have any truly desirable cases, though there are surely things.

Let’s consider PlayerShot:

    def interact_with_invadershot(self, shot, fleets):
        if self.colliding(shot):
            fleets.remove(self)
            fleets.append(ShotExplosion(self.position))

    def interact_with_shield(self, shield, fleets):
        if self.colliding(shield):
            fleets.remove(self)

    def interact_with_topbumper(self, top_bumper, fleets):
        if top_bumper.intersecting(self.position):
            fleets.remove(self)
            fleets.append(ShotExplosion(self.position))

Let’s remove the duplication:

    def interact_with_topbumper(self, top_bumper, fleets):
        if top_bumper.intersecting(self.position):
            self.explode_and_die(fleets)

    def explode_and_die(self, fleets):
        fleets.append(ShotExplosion(self.position))
        self.die(fleets)

    def die(self, fleets):
        fleets.remove(self)

And calls from elsewhere.

I have a possibly better idea. Roll this back. Then from this:

    def interact_with_topbumper(self, top_bumper, fleets):
        if top_bumper.intersecting(self.position):
            fleets.remove(self)
            fleets.append(ShotExplosion(self.position))

Extract this:

    def interact_with_topbumper(self, top_bumper, fleets):
        if top_bumper.intersecting(self.position):
            self.remind_me_to_explode_and_die(fleets)

    def remind_me_to_explode_and_die(self, fleets):
        fleets.remove(self)
        fleets.append(ShotExplosion(self.position))

And then:

    def remind_me_to_explode_and_die(self, fleets):
        self.remind_me_to_die(fleets)
        self.remind_me_to_explode(fleets)

    def remind_me_to_explode(self, fleets):
        fleets.append(ShotExplosion(self.position))

    def remind_me_to_die(self, fleets):
        fleets.remove(self)

And then in the two remind methods, extract the actuals:

    def remind_me_to_explode_and_die(self, fleets):
        self.remind_me_to_die(fleets)
        self.remind_me_to_explode(fleets)

    def remind_me_to_explode(self, fleets):
        self.actually_explode(fleets)

    def actually_explode(self, fleets):
        fleets.append(ShotExplosion(self.position))

    def remind_me_to_die(self, fleets):
        self.actually_die(fleets)

    def actually_die(self, fleets):
        fleets.remove(self)

Then in the reminders:

    def remind_me_to_explode_and_die(self, fleets):
        self.remind_me_to_die(fleets)
        self.remind_me_to_explode(fleets)

    def remind_me_to_explode(self, fleets):
        fleets.remind_me(self.actually_explode, fleets)

    def remind_me_to_die(self, fleets):
        fleets.remind_me(self.actually_die, fleets)

    def actually_explode(self, fleets):
        fleets.append(ShotExplosion(self.position))

    def actually_die(self, fleets):
        fleets.remove(self)

Some tests break. I want to test in the game first. It works fine. This gives me confidence that the tests just need adjustment to allow for the deferred actions. Might be tricky to change them.

    def test_playershot_dies_on_shield(self):
        fleets = Fleets()
        fi = FI(fleets)
        pos = Vector2(100, 100)
        shield = Shield(pos)
        maker = BitmapMaker.instance()
        shot = PlayerShot(pos)
        assert shot.colliding(shield)
        fleets.append(shot)
        assert fi.player_shots
        shot.interact_with_shield(shield, fleets)
        assert not fi.player_shots

We see an issue with these deferred actions … they’re tricky to test. We could just check to see that it deferred an action and then execute them. Let’s try that.

Yes!

    def test_playershot_dies_on_shield(self):
        fleets = Fleets()
        fi = FI(fleets)
        pos = Vector2(100, 100)
        shield = Shield(pos)
        maker = BitmapMaker.instance()
        shot = PlayerShot(pos)
        assert shot.colliding(shield)
        fleets.append(shot)
        assert fi.player_shots
        shot.interact_with_shield(shield, fleets)
        fleets._execute_reminders()
        assert not fi.player_shots

That’s not too awful. There’s one other. Same issue, works same way.

Commit: refactoring PlayerShot to use reminders. Let’s review the bulk of the class:

class PlayerShot(InvadersFlyer):

    def hit_invader(self, fleets):
        self.remind_me_to_die(fleets)


    def interact_with_invadershot(self, shot, fleets):
        if self.colliding(shot):
            self.remind_me_to_explode_and_die(fleets)

    def interact_with_shield(self, shield, fleets):
        if self.colliding(shield):
            self.remind_me_to_die(fleets)

    def interact_with_topbumper(self, top_bumper, fleets):
        if top_bumper.intersecting(self.position):
            self.remind_me_to_explode_and_die(fleets)

    def remind_me_to_explode_and_die(self, fleets):
        self.remind_me_to_explode(fleets)
        self.remind_me_to_die(fleets)

    def remind_me_to_explode(self, fleets):
        fleets.remind_me(self.actually_explode, fleets)

    def remind_me_to_die(self, fleets):
        fleets.remind_me(self.actually_die, fleets)

    def actually_explode(self, fleets):
        fleets.append(ShotExplosion(self.position))

    def actually_die(self, fleets):
        fleets.remove(self)

Now that’s the kind of code I like. Tiny methods, each doing just one tiny thing. YMMV of course.

Summary

Converting our reminders to a list made Fleets simpler. Creating a tiny private _Reminders object made it simpler still.

Then we converted PlayerShot to defer all its fleets appends and removes using reminders. This was not strictly necessary, but it has the effect of isolating PlayerShot such that if those called methods did other things, they would be just fine.

I consider that last bit more of a learning experiment. It certainly wasn’t necessary, but it builds up facility with the new feature, and gives us a better sense of how to use it.

I’m wondering now how to deal with the dual mask situation in Shield. I wonder if we can manage not to need the cached mask at all, perhaps by deferring specific edits to the mask and shield. I’ll save that one for tomorrow, I think. Let’s review briefly just to think about it.

class Shield(InvadersFlyer):

    def interact_with_invadershot(self, shot, fleets):
        self.process_shot_collision(shot, self._explosion_mask)

    def interact_with_playerexplosion(self, explosion, fleets):
        pass

    def interact_with_playershot(self, shot, fleets):
        self.process_shot_collision(shot, self._player_explosion_mask)

    def process_shot_collision(self, shot, explosion_mask):
        collider = Collider(self, shot)
        if collider.colliding():
            mask: Mask = collider.overlap_mask()
            self.erase_shot_and_explosion_from_mask(shot, collider, mask, explosion_mask)
            self.erase_visible_pixels(mask.get_rect())

    def erase_shot_and_explosion_from_mask(self, shot, collider, shot_overlap_mask, explosion_mask):
        self._mask_copy.erase(shot_overlap_mask, (0, 0))
        self.erase_explosion_from_mask(collider, explosion_mask, shot)

    def erase_explosion_from_mask(self, collider, 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_copy.erase(explosion_mask, adjust_image_to_center)

    def erase_visible_pixels(self, shot_rect):
        surf = self._mask_copy.to_surface()
        self._map.blit(surf, shot_rect)

I guess what we’ll need to do is to pass in the various rectangles and overlaps and such, and apply them cumulatively in case, by chance, we were to have two events in the same cycle. A bit of renaming will help as well, I suspect. With luck it will turn out to be more clear.

We’ll give it a go. And I’ve also had an interesting idea from Tomas. We’ll talk about that next time as well.

See you then!