Python Asteroids+Invaders on GitHub

We’ll continue with the Saucer. We’ll look at what’s left and pick something. Includes thrilling video.

Given that I have this jira.md, maybe I should put notes in it having to do with the current story. As things stand, I look back at earlier articles.

Jira!
Do you see how these Jira things sneak up on you? Used well, they’re surely of some value. But they’re not a substitute for having a dynamic relationship with our customers or our team.

Here’s the list from early yesterday:

  • Flies at constant height and speed TBD
  • 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?)
  • Shares space/time with squiggly (do not implement).
  • Flies if > 7 aliens exist
  • Flies every 600 game loops (10 seconds)
  • Start and stop near the side bumpers?
  • Move more magic numbers to 'u'
  • Some values in `u` do not have UPPER_CASE names

I’ve left the height / speed one just because the values are still pretty arbitrary and we should probably review them.

Both the saucer direction and the score depend on player shot count. (I believe the counter restarts at each level. We don’t even have levels yet.) The direction needs to be determined at the time of the Saucer’s __init__, or at least no later than end_interactions. Currently no object knows the player shot count.

It seems reasonable that the player would know her shot count. So we could interact_with_invader_player and fetch the score from her and store it in a Saucer member, to be used in some useful way. Scoring will be easy, we just use the value in a table lookup. Initializing will be a bit more tricky, so let’s do scoring first.

Scoring Plan

  • Cause InvaderPlayer to save the shot count. We’ll assume that a new player starts a new count, at least until someone informs us otherwise.
  • Cause InvadersSaucer to observe the player and snatch the shot count on every cycle.
  • Ah. Cause saucer to interact with shots and explode or at least die.
  • Accumulate score.

I will surely want to TDD all this because testing in the game would require actually shooting down the saucer and I’m unlikely to be very good at that.

Let’s do the saucer-shot interaction first, with a constant score.

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

That seems reasonable. Let’s make it work. It’s currently failing on the final line. That’ll be because saucer doesn’t implement the interaction. Let’s do that:

    def interact_with_playershot(self, shot, fleets):
        self.die(fleets)

Test passes. This should be interesting, in that every time I fire, the saucer will die. Let’s leave it that way, it’ll be interesting in the scoring. I’ll note on a card to write a test missing the saucer.

Commit: Saucer dies on every shot. I couldn’t resist trying it in the game and sure enough as soon as I fire, the saucer vanishes. Note on card: kill shot if it hits saucer.

Let’s move to scoring, since we have a great chance to see it in operation in the game.

Note that this is a change to our plan. I’m not going to write a new plan, I’m just going to step in a different direction than I had expected.

New test. I’m tempted to do a big one but let’s stick to small steps here.

    def test_first_score(self):
        fleets = Fleets()
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        shot = PlayerShot()
        shot.position = saucer.position
        saucer.interact_with_playershot(shot, fleets)
        assert keeper.total_score == 100

Fails, getting zero. Quelle surprise. Implement:

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

Test does not run. Why? Because the keeper hasn’t had a chance to collect the score. Let’s just fetch the score out and check it. We will need the FleetsInspector for this. Change the test:

    def test_first_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        shot = PlayerShot()
        shot.position = saucer.position
        saucer.interact_with_playershot(shot, fleets)
        score = fi.scores[0]
        assert score.score == 100

Green. Commit: saucer always scores 100. still dies on any shot.

Now let’s do the scoring based on player shot count. TDD the count.

The player firing logic is this:

    def trigger_pulled(self, fleets):
        if self.fire_request_allowed:
            self.attempt_firing(fleets)
        self.fire_request_allowed = False

    def attempt_firing(self, fleets):
        if self.free_to_fire:
            fleets.append(PlayerShot(self.rect.center))

Clearly we can do the counting there. There are usages of attempt_firing, so we must have tests that we can add to.

But wait. Let’s extract a method from attempt_firing. We can do that without testing.

    def attempt_firing(self, fleets):
        if self.free_to_fire:
            self.fire(fleets)

    def fire(self, fleets):
        fleets.append(PlayerShot(self.rect.center))

Now, you see, I can test fire for accumulating the count, without much setup at all.

    def test_firing_counts_shots(self):
        player = InvaderPlayer()
        assert player.shot_count == 0

Fails. Init:

class InvaderPlayer(InvadersFlyer):
    def __init__(self):
        maker = BitmapMaker.instance()
        self.players = maker.players  # one turret, two explosions
        ...
        self.fire_request_allowed = True
        self.shot_count = 0

Green. Commit: player has shot_count member.

Silly to commit? Took less time to do than it took to tell you and now I have a save point closer than it was before.

Enhance the test:

    def test_firing_counts_shots(self):
        player = InvaderPlayer()
        assert player.shot_count == 0
        player.fire([])
        assert player.shot_count == 1
        player.fire([])
        assert player.shot_count == 2

Improve the fire method:

    def fire(self, fleets):
        self.shot_count += 1
        fleets.append(PlayerShot(self.rect.center))

Green. Commit: player knows its shot_count.

Breathe

I’ve been moving rapidly. Not too rapidly, but I feel that since we’re about to change direction I should pause and take a few deep breaths.

As I do that, I realize that I’ve not even been taking bites of my breakfast banana, nor have I had even one sip of my morning iced chai. Clear sign of moving a bit more rapidly than might be ideal. I think we’re OK but would be better not to be moving at ten-tenths. There’s no rush.

Next will be to test the scores to see if they follow the list. We will implement the list of 15, not 16, because the bug in the original game only used 15 of the 16 entries.

I’ll write the test longhand. I could give it the list, but then I would be inclined to copy and paste the list and that would make the test a bit naff. So first, I enhance the test like this:

    def test_first_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        shot = PlayerShot()
        shot.position = saucer.position
        
        def kill_saucer(expecting):
            saucer.interact_with_playershot(shot, fleets)
            score = fi.scores[-1]
            assert score.score == expecting
        kill_saucer(100)

With the little helper function, now I can do this:

    def test_first_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        shot = PlayerShot()
        shot.position = saucer.position

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

        kill_saucer(150)
        kill_saucer(100)
        kill_saucer(100)
        kill_saucer(50)

        kill_saucer(300)
        kill_saucer(100)
        kill_saucer(100)
        kill_saucer(100)

        kill_saucer(50)
        kill_saucer(150)
        kill_saucer(100)
        kill_saucer(100)

I think those are right. The test fails, on the 50, getting 100, as one would expect. In the actual code, I’m going to do something different. Hold my chai.

In the saucer we have this:

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

This is a bigger bite than I “should” take, because I’ve not yet driven out the capturing of the player. I’ll take a chance and continue with the big bite. Perhaps I’ll choke, perhaps not. I seem fairly adept today.

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

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

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

I do not have score list yet. I go to the original source and paste this:

        self._score_list= [10, 05, 05, 10, 15, 10, 10, 05, 30, 10, 10, 10, 05, 15, 10]

Actually there were no commas in the original. I typed those in before I copied the line above. Sorry.

Now those values all need appended zeros, and by the way, Python will not let you write 05 for 5. So:

        self._score_list = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]

I sort of expected the tests to run now. But they do not. The error tells me what I forgot:

>       score_index = self._player.shot_count % len(self._score_list)
E       AttributeError: 'NoneType' object has no attribute 'shot_count'

In my test, the saucer has not seen the player, so _player is None.

Let’s protect the code with a check, returning a bogus score of zero.

    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 fixes the test that doesn’t check score. The other one needs improvement:

    def test_first_score(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        fleets.append(keeper := InvaderScoreKeeper())
        fleets.append(player := InvaderPlayer())
        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)
        kill_saucer(50)
        kill_saucer(50)
        kill_saucer(100)
        ...

Still no joy. Did I take too big a bite? Almost certainly. Am I going to roll back and do over? Not yet.

I forgot to give the player to the saucer. The test:

    def test_first_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)  # added
        shot = PlayerShot()
        shot.position = saucer.position
        ...

We are green. Commit: saucer score depends properly on player shot count. Still dies on every shot. Go rack up a huge score.

It’s clear to me that I’ll never hit the thing legitimately, but I nailed it a number of times with the current kill logic. Here’s a video of me scoring 15 points in the most cowardly fashion available to me:

We’re about 90 minutes in. Let’s call it a morning.

Summary

Here’s what’s left to do, based on my note card and the initial list:

  • 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 (see Added in Post below)
  • 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?)
Added in Post
I see that I planned to snatch the shot_count from player during the interaction but instead snatched the player. I was probably thinking that fetching the count right as we collide would be more accurate. But in fact it isn’t … because we will be interacting with a player shot and there can be only one at a time. So we should fix that to work the other way, saving the check for None while we’re at it.

So that’s pretty good progress and even the big bite went fairly well. I was a bit worried there for a moment, since there were at least two mistakes along the way to doing that last test.

Today was much less ragged than the two preceding days. I just seemed a bit more capable. The past few days I’ve had an irritated eye and I think that was throwing me off. It seems a bit better today, maybe that’s part of it.

The thing is, as a complex system comprised of a vast number of cells most of which don’t even have my DNA in them, my behavior varies widely based on all kinds of things including the weather and what I’ve recently eaten, not to mention politics, and what I’m reading. It’s frankly surprising that we humans can even manage to show up at the same place twice in a row, and it’s simply unrealistic to imagine that we’ll program at our peak every day. We’re lucky to hit our average!

So what’s to do? Well, I try to pay attention to how things are going, and to slow down when things get ragged. I almost always at least realize that they’re getting ragged, and sometimes I actually manage to slow down. Once in a while I even roll back to a save point and proceed more carefully.

Paying attention to how I feel about the work, and just how I feel, gives me a chance to adjust my behavior to fit with my effectiveness. I don’t always make good use of the chance, but I do try.

What should you do? You can’t trick me: I don’t give advice. What do you do when you feel the wheels coming off your programming wagon? Is it something productive? I hope so!

See you next time!