Python Asteroids+Invaders on GitHub

Let’s see what’s left for the saucer and what we can do. It’s late Saturday morning: maybe we won’t do much.

My jira.md notes say:

Current

  • test missing the saucer
  • kill the shot if it hits saucer
  • make an explosion (just thought of this)
  • Flies at constant height and speed TBD
  • change to snatch shot_count, not player (Works but seems hard to grok)
  • Direction: Comes from right if player shot count is even
  • Scores: 10 05 05 10 15 10 10 05 30 10 10 10 05 15 10 05
  • Score loops at 15, not 16 (replicate this bug?)

That’s almost handy, if I would keep it up to date. We’ll start on that first one, because the saucer just up and dies whenever there’s a player shot on the screen. Good for me, not really much for the game.

class InvadersSaucer(InvadersFlyer):

    def interact_with_playershot(self, shot, fleets):
        fleets.append(InvaderScore(self.mystery_score()))
        self.die(fleets)

I note also this item:

  • change to snatch shot_count, not player (see Added in Post below)

Let’s divert and do that first. (Planning is important, but I’m not a fanatic about following the plan when the terrain says otherwise.)

class InvadersSaucer(InvadersFlyer):
    def __init__(self, direction=1):
        self.direction = direction
        maker = BitmapMaker.instance()
        self.saucers = maker.saucers  # one turret, two explosions
        self._map = self.saucers[0]
        self._mask = pygame.mask.from_surface(self._map)
        self._rect = self._map.get_rect()
        half_width = self._rect.width // 2
        self._left = u.BUMPER_LEFT + half_width
        self._right = u.BUMPER_RIGHT - half_width
        self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)
        self._speed = 8
        self._player = None
        self._score_list = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]


    def interact_with_invaderplayer(self, player, fleets):
        self._player = player

    def mystery_score(self):
        if not self._player:
            return 0
        score_index = self._player.shot_count % len(self._score_list)
        return self._score_list[score_index]

That should be this:

class InvadersSaucer(InvadersFlyer):
    def __init__(self, direction=1):
        self.direction = direction
        maker = BitmapMaker.instance()
        self.saucers = maker.saucers  # one turret, two explosions
        self._map = self.saucers[0]
        self._mask = pygame.mask.from_surface(self._map)
        self._rect = self._map.get_rect()
        half_width = self._rect.width // 2
        self._left = u.BUMPER_LEFT + half_width
        self._right = u.BUMPER_RIGHT - half_width
        self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)
        self._speed = 8
        self._player_shot_count = 0
        self._score_list = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]

    def interact_with_invaderplayer(self, player, fleets):
        self._player_shot_count = player.shot_count

    def mystery_score(self):
        score_index = self._player_shot_count % len(self._score_list)
        return self._score_list[score_index]

A test fails, this one:

    def test_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        fleets.append(player := InvaderPlayer())
        saucer.interact_with_invaderplayer(player, fleets)
        shot = PlayerShot()
        shot.position = saucer.position

        def kill_saucer(expecting):
            saucer.interact_with_playershot(shot, fleets)
            score = fi.scores[-1]
            assert score.score == expecting
            player.fire(fleets)
        kill_saucer(100)
        ...

Why? Because, since we don’t hold on to the player any more, we need our test to show us the player every time around, so that we can fetch the actual count. Let me jigger the test a bit.

        def kill_saucer(expecting):
            saucer.interact_with_playershot(shot, fleets)
            score = fi.scores[-1]
            assert score.score == expecting
            player.fire(fleets)
            saucer.interact_with_invaderplayer(player, fleets)

This passes.

He’s gonna change his mind, folks! Go, Ron! Listen to the code!

There is an interesting fact, however. If the interact_with_invaderplayer is put after the interact_with_playershot, the test fails, because the shot count will have been ticked but the saucer hasn’t seen it yet. In actual play, the shot is fired long before the saucer will encounter it, so it is sure to have the correct count.

But it is odd. I think the code is more clear when we hold on to the player. Let’s revert those changes and line out that item.

Reflection

Saving shot_count instead of player really seemed like a good idea, and in fact it would work. But it depends on there being more than one game cycle between firing and the saucer seeing the shot. That will always happen, but it is a strange temporal dependency that I think is confusing. It kind of makes the mind stumble a bit. So we’re better off without it: it’s just a bit tricky. Best not to be tricky when we don’t need to be.

Curiously, the existing scheme is a bit more efficient as well. As the code stands, we store the player on every tick. Storing shot_count, we fetch and store the shot count on ever tick. I find that amusing. It wasn’t even that good an idea to begin with!

Where were we?

Oh, right, write a test for missing the saucer.

    def test_missing_shot(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        shot = PlayerShot()
        shot.position = Vector2(0, 0)
        assert fi.invader_saucers
        saucer.interact_with_playershot(shot, fleets)
        assert fi.invader_saucers

This test fails on the last line, because the saucer apparently dies of fright whenever it sees a player shot. We need it to do the collision checking thing. Let’s review how a few of those are done. Here’s one:

class RoadFurniture(InvadersFlyer):
    def process_shot_collision(self, shot):
        if Collider(self, shot).colliding():
            self._tasks.remind_me(lambda: self.mash_image(shot))

That’s the shields and bottom line code. I feel the need to drill into Collider and its helper.

class Collider:
    def __init__(self, left, right):
        self.left_masker = Masker(left.mask, left.position)
        self.right_masker = Masker(right.mask, right.position)

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

No, let’s stop here. I do think we’ll wish for more tests but that this will just work: collider has seen lots of use.

I do wonder about the remind_me. I’m not sure why we had to defer the image mashing. Not our problem. Let’s just go for the direct case here.

class InvadersSaucer(InvadersFlyer):

    def interact_with_playershot(self, shot, fleets):
        if Collider(self, shot).colliding():
            fleets.append(InvaderScore(self.mystery_score()))
            self.die(fleets)

Test passes. I really think this is done, but the tests are a bit thin. We could gin up some more, putting the shot near the saucer but not touching, barely touching, and so on, but Collider works, therefore this works.

Commit: Saucer uses Collider to determine whether hitting shot.

Exploding

The saucer just has one frame of explosion. It appears that what one does is show that frame for a while. You’re also supposed to display the mystery score, floating nearby. I haven’t found a video showing just what happens, so I’m trying to grasp the code.

So what we need here is that when we’re hit, we change our display to the explosion, wait a while, then die. Or … we could create a timed explosion object, toss it into the mix, and then die immediately. We do the latter, for example, with the player shot explosion:

class ShotExplosion(InvadersFlyer):
    def __init__(self, position):
        self.position = position
        maker = BitmapMaker()
        self.image = maker.player_shot_explosion
        self._mask = pygame.mask.from_surface(self.image)
        self._rect = self.image.get_rect()
        self.time = 0.125

    @property
    def mask(self):
        return self._mask

    @property
    def rect(self):
        return self._rect

    def tick(self, delta_time, fleets):
        self.time -= delta_time
        if self.time < 0:
            fleets.remove(self)

    def draw(self, screen):
        self.rect.center = self.position
        screen.blit(self.image, self.rect)

    def interact_with(self, other, fleets):
        other.interact_with_shotexplosion(self, fleets)

Seems like we could reuse this object, making a version for player shot and a version for saucer explosion.

Let’s refactor to a class method for creating the shot explosion. We’ll want to pass in position, the bitmap, and the desired explosion time, since those will clearly vary.

class ShotExplosion(InvadersFlyer):

    @classmethod
    def shot_explosion(cls, position, time):
        maker = BitmapMaker()
        image = maker.player_shot_explosion
        return cls(image, position, time)

    def __init__(self, image, position, time):
        self.position = position
        self._mask = pygame.mask.from_surface(image)
        self._rect = image.get_rect()
        self._time = time

Now there are references to ShotExposion that need to be fixed up.

There are but two and there should be only one:

class PlayerShot(InvadersFlyer):

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

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

Let’s refactor and then fix.

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

    def explode(self, fleets):
        fleets.append(ShotExplosion(None, self.position, 0.125))
        fleets.remove(self)

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

And then:

    def explode(self, fleets):
        fleets.append(ShotExplosion.shot_explosion(self.position, 0.125))
        fleets.remove(self)

That should be just fine. I’ll run to be sure. I’m certainly glad that I did that!

    screen.blit(self.image, self.rect)
                ^^^^^^^^^^
AttributeError: 'ShotExplosion' object has no attribute 'image'

Oops.

    def __init__(self, image, position, time):
        self.image = image
        self.position = position
        self._mask = pygame.mask.from_surface(image)
        self._rect = image.get_rect()
        self._time = time

Try again. Works. One more thing, rename the class to InvadersExplosion. That requires me to rename the interaction method as well. That works as advertised. Tests go back to green. Run game once more to be sure. Yes.

Commit: refactor ShotExplosion to generally useful InvadersExplosion in prep for saucer.

Now we should be able to cause the saucer to create a suitable explosion. First provide the new class method:

    @classmethod
    def saucer_explosion(cls, position, time):
        maker = BitmapMaker()
        image = maker.saucers[1]
        return cls(image, position, time)

And to make it happen in InvadersSaucer:

    def interact_with_playershot(self, shot, fleets):
        if Collider(self, shot).colliding():
            fleets.append(InvaderScore(self.mystery_score()))
            self.die(fleets)

We do this:

    def interact_with_playershot(self, shot, fleets):
        if Collider(self, shot).colliding():
            explosion = InvadersExplosion.saucer_explosion(self.position, 0.5)
            fleets.append(explosion)
            fleets.append(InvaderScore(self.mystery_score()))
            self.die(fleets)

To test it, however, is another matter. I’ll force it:

    def interact_with_playershot(self, shot, fleets):
        if True or Collider(self, shot).colliding():
            explosion = InvadersExplosion.saucer_explosion(self.position, 0.5)
            fleets.append(explosion)
            fleets.append(InvaderScore(self.mystery_score()))
            self.die(fleets)

That breaks my test for missing but it means I should see the explosion in play, every time I fire a shot with the saucer on screen. Works as shown below. Remove the True or. Green. Commit: Saucer explodes for 0.25 seconds when hit. Good luck with that.

I think that will suffice for the morning, since it is now actually afternoon.

Summary

This was a pleasant and stress-free morning. It was interesting that once I had actually done the change from saving the player to saving the shot count, I saw that it was less clear. And I am proud of myself for backing it out, since it worked perfectly and absolutely would continue to do so. And I’m amused to notice the tiny efficiency advantage of the original version. Saved literally nanoseconds there, I’m sure.

The decision not to test the collisions more carefully was a judgment call and could be wrong. Collider is used a lot and even has tests, so I think we can trust it, but I freely grant that if it were really easy to do some partial collision checks I’d have done some. The effort was large enough that I made the call not to do it. I am confident that it was a good call. And, of course, I could be wrong.

What should our Jira say now?

  • kill the shot if it hits saucer
  • display mystery score
  • Flies at constant height and speed TBD
  • Direction: Comes from right if player shot count is even
  • test missing the saucer
  • make an explosion
  • change to snatch shot_count, not player (too hard to understand)
  • Scores: 10 05 05 10 15 10 10 05 30 10 10 10 05 15 10 05
  • Score loops at 15, not 16 (replicate this bug?)

The explosion is also causing me to preen a bit, because we were able to reuse the ShotExplosion object, generalizing it to InvadersExplosion, with two class methods one for the shot and one for the saucer. Nice one, team, avoided a lot of work and a lot of duplicate code. Well done!

We still have a few little things to do with the Saucer, including displaying the score. I wonder if we can use the InvadersExplosion object to do that as well. Might be a reach.

We’ll see, next time. Join me if you dare!