Python Asteroids+Invaders on GitHub

In the unlikely event that I hit the saucer, it is supposed to display the “mystery score” that applies. I think we can do that.

We do have the mystery score implemented:

class InvadersSaucer(SpritelyMixin, InvadersFlyer):
    def interact_with_playershot(self, shot, fleets):
        if self.colliding(shot):
            self.explode_scream_and_die(fleets)

    def explode_scream_and_die(self, fleets):
        fleets.append(InvaderScore(self._mystery_score()))
        Exploder.explode_saucer(self.position, fleets)
        fleets.remove(self)

Right there in explode_scream_and_die, we apply the score. We can display it right from there. Should we change the method name to explode_scream_score_and_die? Or we could remove scream since that is now handled automatically in the Exploder:

class Exploder:
    @classmethod
    def explode_saucer(cls, position, fleets):
        saucer_explosion_sound = "ufo_highpitch"
        explosion = GenericExplosion.saucer_explosion(position, 0.5)
        cls.explode(position, saucer_explosion_sound, explosion, fleets)

But speaking of the Exploder, why couldn’t the saucer have an explosion that looks like the score? That’s my cunning plan: to use Exploder to display the score. We need to look at the GenericExplosion to see how it works:

class GenericExplosion(InvadersFlyer):
    @classmethod
    def saucer_explosion(cls, position, time):
        maker = BitmapMaker()
        image = maker.saucer_explosion
        return cls((image,), position, time)

Ha! So if we were to make an image of a number, which surely we must be able to do, we could make the saucer have a second explosion that was its score. We’ll want to space it a bit away from the saucer, but that should be no problem.

So our mission includes:

  1. Make an image of a numeric value. There must be a clue for this in the score display
  2. Make a GenericExplosion of that image. This will be a trivial class method like the others.
  3. Make an Exploder method that adds that explosion, with no additional sound.
  4. Call that method from explode_etc_etc when the saucer gets it.

The hard part will be testing this in the game, since neither I nor the robot are good at hitting the saucer.

The numeric display

Let’s have a quick glance at what the image is in a GenericExplosion. I think it’s probably a Surface. It is:

        self.saucer_explosion = self.make_and_scale_surface(saucer_explosion, scale, (24, 8), "red")

The InvaderScoreKeeper makes the score that it displays like this:

class InvaderScoreKeeper(InvadersFlyer):
...
        self.score_font = pygame.font.SysFont("andale mono", 48)
...

    def draw(self, screen):
        header = "SCORE<1>"
        header_surface = self.score_font.render(header, True, "white")
        screen.blit(header_surface, (75, 10))
        score_text = f"0000{self.total_score}"[-5:]
        score_surface = self.score_font.render(score_text, True, "white")
        screen.blit(score_surface, (135, 60))

This is all going to be display work, so I’m not sure what, if anything, I should test. We’ll just go ahead and see if something actually useful can be tested. Certainly we can check to be sure that a mystery score goes into the mix.

Here, we want a generic explosion like the others, except with a numeric value to display.

Slight change of direction

I’ve been thinking about the bottom level here, getting the numeric surface. Instead, let’s start “top down”, which will give us a better sense of what we need, and where to get it. Do we have a test for the saucer getting hit? In fact, unbelievers, we have. In fact we have two tests relating to that. Here’s the most promising one:

    def test_dies_if_hit(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        shot = PlayerShot()
        fleets.append(shot)
        shot.position = saucer.position
        assert fi.invader_saucers
        assert fi.player_shots
        saucer.interact_with_playershot(shot, fleets)
        shot.interact_with_invaderssaucer(saucer, fleets)
        assert not fi.invader_saucers
        assert not fi.player_shots

Let’s emulate this one and check for more of the game state after saucer hit.

    def test_mystery_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        shot = PlayerShot()
        fleets.append(shot)
        shot.position = saucer.position
        assert fi.invader_saucers
        assert fi.player_shots
        saucer.interact_with_playershot(shot, fleets)
        shot.interact_with_invaderssaucer(saucer, fleets)
        assert not fi.invader_saucers
        assert not fi.player_shots
        score = fi.scores[0]
        assert score.score == 100
        explosions = fi.invader_explosions
        assert len(explosions) == 2

This test is failing with len(explosions) equal to one, as expected. Should we shorten it to make it more clear what it cares about? Let’s do:

    def test_mystery_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        shot = PlayerShot()
        fleets.append(shot)
        shot.position = saucer.position
        saucer.interact_with_playershot(shot, fleets)
        shot.interact_with_invaderssaucer(saucer, fleets)
        score = fi.scores[0]
        assert score.score == 100
        explosions = fi.invader_explosions
        assert len(explosions) == 2

Now let’s make it pass, trivially:

    def explode_scream_and_die(self, fleets):
        fleets.append(InvaderScore(self._mystery_score()))
        Exploder.explode_saucer(self.position, fleets)
        Exploder.explode_saucer(self.position, fleets)
        fleets.remove(self)

Now the test finds two explosions, but they are rather redundant. And we have a place to stand.

Recall that we want the numeric explosion to be offset from the saucer position. Should that be decided here? I think so … we’re the last object that will naturally know the saucer position.

For now, let’s offset the value to the right. We’ll pick a random distance for now. This will want tuning, and when the saucer is past center, we’ll probably display on the left. Tempted to do that right now. No. Small steps.

We could commit right now. Let’s do: initial test for display of mystery score and trivial implementation.

OK, we want to send the Exploder the adjusted position and score, to a new method, like this:

    def explode_scream_and_die(self, fleets):
        fleets.append(InvaderScore(self._mystery_score()))
        Exploder.explode_saucer(self.position, fleets)
        adjusted_position = self.position + Vector2(64, 0)
        Exploder.explode_saucer(adjusted_position, fleets)
        fleets.remove(self)

I can’t resist: I want to see this in operation. I’ll make all player shots fatal to the saucer for now. This will mean that we can’t commit with that change in.

We could divert and build a cheat code. Interesting but no.

    def interact_with_playershot(self, shot, fleets):
        if True or self.colliding(shot):
            self.explode_scream_and_die(fleets)

This breaks a test. That’s good news, it means I probably won’t commit this “feature”. I see two explosions. The separation is probably not enough. We’ll press on for now.

    def explode_scream_and_die(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        Exploder.explode_saucer(self.position, fleets)
        adjusted_position = self.position + Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)
        fleets.remove(self)

This code posits a new method, score_saucer. We create a trivial one via copy:

class Exploder:
    @classmethod
    def score_saucer(cls, score, position, fleets):
        saucer_explosion_sound = ""
        explosion = GenericExplosion.saucer_explosion(position, 0.5)
        cls.explode(position, saucer_explosion_sound, explosion, fleets)

Green. (I removed my hack.) Commit: moving toward display of mystery score.

Push responsibility down to GenericExplosion:

    @classmethod
    def score_saucer(cls, score, position, fleets):
        saucer_explosion_sound = ""
        explosion = GenericExplosion.saucer_score(score, position, 1.0)
        cls.explode(position, saucer_explosion_sound, explosion, fleets)

I upped the display time to 1.0, a full second. We’ll see what we think when we test it in game. We’re red, for lack of the saucer_score method.

    @classmethod
    def saucer_score(cls, score, position, time):
        image = BitmapMaker().saucer_explosion
        if pygame.get_init():
            score_font = pygame.font.SysFont("andale mono", 24)
            image = score_font.render(str(score), True, "white")
        return cls((image,), position, time)

We are green. Note that I had to create an image, because, superstitiously, I copied the idea of only creating a text thing if pygame is initialized. Maybe we’ll try without that, but for now, I wanted to play it safe.

With my hack back in, I expect this to work. Oh this is nice!

Reflection, Converging on Summary

With a clear win in hand, let’s reflect on what has happened and what needs to happen.

Thing one, I am pleased with the idea of making the score display a kind of explosion. It makes that rather unique behavior quite similar to and consistent with a number of other behaviors, and despite “100” being an odd kind of explosion, it fits in quite nicely. I’m proud of me for thinking of it.

Our tests, including the one that checks for two explosions, allowed us to begin “at the top”, first creating just another explosion, then pushing that down to a generic explosion, and then, finally, making the new image of the numeric score. Working top down with tests tracking us made this process quite simple.

It’s also a fairly nice example of what I consider to be good object-oriented programming, with the saucer using the Exploder, using the GenericExplosion , pushing responsibility down until finally someone gives up and does something, and it all unwinds with the desired result.

I say “fairly nice” because while I think this is the way, I’m sure that some of my esteemed colleagues could make it even better. Still it’s an example of pushing responsibility down as far as it can reasonably go.

What is needed? I would like to try removing that if statement that checks for pygame init. I suspect that tests fail if I do that. Yes, we get errors about Font not initialized. So we leave that if.

I’d like the score to appear to the left of the saucer if it is killed past the center. We’re set up to do that:

    def explode_scream_and_die(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        Exploder.explode_saucer(self.position, fleets)
        adjusted_position = self.position + Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)
        fleets.remove(self)

What do I mean by “set up”? Note that I named that temp adjusted_position. Now we want to adjust it.

    def explode_scream_and_die(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        Exploder.explode_saucer(self.position, fleets)
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult*Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)
        fleets.remove(self)

That works as advertised. As I look at that method, I do think it could be factored, but it’s not bothering me much at all. We’ll let it ride for now. Often I find a method acceptable right after working on it and later see ways to improve it, when my mind isn’t so fresh on the details. No … let’s extract a method:

    def explode_scream_and_die(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        Exploder.explode_saucer(self.position, fleets)
        self.show_mystery_score(score, fleets)
        fleets.remove(self)

    def show_mystery_score(self, score, fleets):
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult * Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)

OK, now that we’re at it extract another and reorder things a bit:

    def explode_scream_and_die(self, fleets):
        fleets.remove(self)
        Exploder.explode_saucer(self.position, fleets)
        score = self.accrue_score(fleets)
        self.show_mystery_score(score, fleets)

    def accrue_score(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        return score

Ah, I don’t quite like that thing of returning the score and using it. It’s kind of lopsided. Let’s extract both those:

    def explode_scream_and_die(self, fleets):
        fleets.remove(self)
        Exploder.explode_saucer(self.position, fleets)
        self.accrue_and_display_score(fleets)

    def accrue_and_display_score(self, fleets):
        score = self.accrue_score(fleets)
        self.show_mystery_score(score, fleets)

Inline that new method so we can rearrange it:

    def accrue_and_display_score(self, fleets):
        score1 = self._mystery_score()
        fleets.append(InvaderScore(score1))
        score = score1
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult * Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)

So that was an odd thing that PyCharm did, with the score1 bit. I think I confused it a little. OK. Inline the temp:

    def accrue_and_display_score(self, fleets):
        score1 = self._mystery_score()
        fleets.append(InvaderScore(score1))
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult * Vector2(64, 0)
        Exploder.score_saucer(score1, adjusted_position, fleets)

Rename score1.

    def accrue_and_display_score(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult * Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)

Extract method:

    def explode_scream_and_die(self, fleets):
        fleets.remove(self)
        Exploder.explode_saucer(self.position, fleets)
        self.accrue_and_display_score(fleets)

    def accrue_and_display_score(self, fleets):
        score = self._mystery_score()
        fleets.append(InvaderScore(score))
        self.display_score(score, fleets)

    def display_score(self, score, fleets):
        mult = 1 if self.position.x < u.CENTER.x else -1
        adjusted_position = self.position + mult * Vector2(64, 0)
        Exploder.score_saucer(score, adjusted_position, fleets)

I also rename the method to explode_score_and_die. Finally.

I think that’ll do (pig).

One more thing. We have not tested that the score we display is equal to the score we accrue. And as we see in the code above, the value of the score gets lost, in that the GenericExplosion does not carry a value.

class GenericExplosion(InvadersFlyer):
    def __init__(self, images, position, time):
        self.images = images
        self.position = position
        self._mask = pygame.mask.from_surface(images[0])
        self._rect = images[0].get_rect()
        self._time = time

Could we fix this? Yes … we could add a member to GenericExplosion, say, value, and default it to zero, and set it to the score in the case of a score-type explosion. Worth it? I’m not feeling it just now. We can see from the code that it’s doing the right thing.

Would you let that test slide? Would you have done any tests? Would you have done different ones, or gone another way? I wish I could know.

But for today, we have completed our story in good order. Yay, us!

See you next time!