Python Asteroids+Invaders on GitHub

It’s Sunday morning and I have a little time to play. Let’s see what’s left for the Saucer.

In Other News
My Internet is down, modem lights off, tech scheduled for Tuesday. Sorry for delay. Guess I’ll just keep coding and writing.

My jira.md tab says:

  • 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?)

Let’s do that first one. This is the thing that I like best about the current game design.

As things stand, the PlayerShot ignores the Saucer, because by default, all the objects ignore each other. The superclass implements all the interaction methods as pass (pace Hill).

So all we have to do is this:

    def interact_with_invaderssaucer(self, saucer, fleets):
        if self.colliding(saucer):
            fleets.remove(self)

That should do the trick. I think I’ll enhance a test to check it, since me shooting down a saucer is quite unlikely. (I will set some bits and check it manually, in a moment.)

    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

Test is green. Commit (and don’t push): Player shot dies on collision with saucer.

Reflection

I hope you can see why I really like this design, with a message for every occasion, but you only need to listen to the ones you care about. One tiny method and the shot dies upon colliding with the saucer. Very tasty. You do have to get used to thinking in the style of this design, but when it’s good it’s great.

Of course there are tons of messages buzzing about, space is full of messages, but we have a fast computer and their cost is low. In a different situation, we’d do a different kind of thing. Here, well, I like it.

What else is there?

  • display mystery score
  • Flies at constant height and speed TBD
  • Direction: Comes from right if player shot count is even

Let’s do that last one. It might be tricky, because at creation time we do not know the shot count. Let’s examine the saucer and see what we might do.

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 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)

    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]

We do not know the player until after that interaction occurs, so that, in principle, we shouldn’t refer to it until after end_interactions. We do, however, refer to it before that, when we decide the score, but I would argue that it’s legitimate because we will know the player long before we interact with the shot.

This is what my colleagues do not like about this design, and just now, it’s what I don’t like about it: sometimes we need to think about the timing of events. That’s often true with event-driven systems, but it’s never pleasant.

Let’s stick to our current concern, the position and direction of the saucer.

What if we did something like this: we could have a flag (this is already a bit stinky, flags aren’t exactly lovely), a flag that says whether we have initialized the saucer’s position and direction. In draw, we’d better check that, so as not to draw until it’s time to do so. Perhaps true in update as well.

OMG, what if there is no player on screen when the saucer starts out? What should we do? What does the original game do? I do not know. I think we do know that if there is no player, we cannot init, unless we do it randomly. Table that concern.

Flag saying whether we’re running yet. Check as needed in draw and update. In end-interactions, if we have the player, and the flag’s not set, we can initialize.

Let’s try that.

I’m going to spike this, or, more accurately, I am going to code this because I don’t want to test it.

Oh, OK, I’ll test it, or at least try.

    def test_initialize(self):
        player = InvaderPlayer()
        saucer = InvadersSaucer()
        assert not saucer.initialized
        saucer.interact_with_invaderplayer(player, [])
        saucer.end_interactions([])
        assert saucer.initialized

That’s not really too awful. Implement:

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]
        self.initialized = False

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            self.initialized = True

Test is green. Commit: saucer initialized flag.

Now a test to get it to actually do the work. We’re supposed to come from the right if the shot count is even, which it will be with a fresh player, so we can do this:

    def test_start_on_right(self):
        player = InvaderPlayer()
        player.shot_count = 0
        saucer = InvadersSaucer()
        saucer.interact_with_invaderplayer(player, [])
        saucer.end_interactions([])
        assert saucer.position.x > u.CENTER.x
        assert saucer._speed < 0

Rather than check exact coordinates and speed, which are subject to tuning, I’ll check that the saucer is on the right side of center and moving with negative speed, i.e. right to left. Test fails, of course. We’re not there yet.

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            self.initialized = True
            speed = 8
            if self._player.shot_count%2 == 0:
                self._speed = -speed
                self.rect.center = Vector2(self._right, u.INVADER_SAUCER_Y)
            else:
                self._speed = speed
                self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)

The test goes green. I want to see this in the game. I think it might do something interesting, since we’re not checking the initialized flag in draw or update, and I want to see it. Forgive me.

It does do something interesting but not what I though. I was thinking it might flicker onto the screen and then shift to the other side. If it did, it wasn’t visible. But it does seem that after it goes right to left you never see another saucer. I bet we’re not checking both sides.

    def update(self, delta_time, fleets):
        x = self.position.x + self._speed
        if x > self._right:
            self.die(fleets)
        else:
            self.position = (x, self.position.y)

OK, I admit it, we should have a test for that. We probably don’t even have one for the side that works.

I do have a test that almost happens to test left to right. I rename it:

    def test_returns_after_dying_on_right(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        stop_loop = 10000
        while fi.invader_saucers and stop_loop > 0:
            saucer.update(1/60, fleets)
            stop_loop -= 1
        assert stop_loop > 0
        assert not fi.invader_saucers
        assert fi.time_capsules

This is assuming speed will be left to right. Let’s break this test and then improve it and write the opposite one. I’d working because _speed is set in init. It need not be. When I remove that setting in the init, four tests break. Nice.

Let’s extract a method from our end_interactions, to do the init, so that we can test more easily.

We start with this:

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            self.initialized = True
            speed = 8
            if self._player.shot_count%2 == 0:
                self._speed = -speed
                self.rect.center = Vector2(self._right, u.INVADER_SAUCER_Y)
            else:
                self._speed = speed
                self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)

I’d like to pass in the shot count, so we’ll refactor to this:

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            self.initialized = True
            shot_count = self._player.shot_count % 2
            speed = 8
            if shot_count == 0:
                self._speed = -speed
                self.rect.center = Vector2(self._right, u.INVADER_SAUCER_Y)
            else:
                self._speed = speed
                self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)

That was Extract Variable and move line up. Now:

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            self.initialized = True
            shot_count = self._player.shot_count % 2
            self.init_motion(shot_count)

    def init_motion(self, shot_count):
        speed = 8
        if shot_count == 0:
            self._speed = -speed
            self.rect.center = Vector2(self._right, u.INVADER_SAUCER_Y)
        else:
            self._speed = speed
            self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)

I have two tests failing:

    def test_saucer_moves(self):
        saucer = InvadersSaucer()
        start = saucer.position
        fleets = Fleets()
        fleets.append(InvaderFleet())
        saucer.update(1.0/60.0, fleets)
        assert saucer.position.x != start.x

We need to call init here.

    def test_saucer_moves(self):
        saucer = InvadersSaucer()
        saucer.init_motion(0)
        start = saucer.position
        fleets = Fleets()
        fleets.append(InvaderFleet())
        saucer.update(1.0/60.0, fleets)
        assert saucer.position.x != start.x

Green. And this fails as expected:

    def test_returns_after_dying_on_right(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        stop_loop = 10000
        while fi.invader_saucers and stop_loop > 0:
            saucer.update(1/60, fleets)
            stop_loop -= 1
        assert stop_loop > 0
        assert not fi.invader_saucers
        assert fi.time_capsules

This one wants left to right, so init it with 1 (odd):

    def test_returns_after_dying_on_right(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        saucer.init_motion(1)
        stop_loop = 10000
        while fi.invader_saucers and stop_loop > 0:
            saucer.update(1/60, fleets)
            stop_loop -= 1
        assert stop_loop > 0
        assert not fi.invader_saucers
        assert fi.time_capsules

Green. Make the left-side test, which will fail:

    def test_returns_after_dying_on_left(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(saucer := InvadersSaucer())
        saucer.init_motion(0)  # even right to left
        stop_loop = 10000
        while fi.invader_saucers and stop_loop > 0:
            saucer.update(1/60, fleets)
            stop_loop -= 1
        assert stop_loop > 0
        assert not fi.invader_saucers
        assert fi.time_capsules

That times out. I love that I did that. Now the fix:

    def update(self, delta_time, fleets):
        x = self.position.x + self._speed
        if x > self._right or x < self._left:
            self.die(fleets)
        else:
            self.position = (x, self.position.y)

Green. Commit: saucer now dies properly both left and right.

I think the saucer code could use some improvement. Let’s look around.

class InvadersSaucer(InvadersFlyer):
    def __init__(self, direction=1):
        self.direction = direction
        maker = BitmapMaker.instance()
        self.saucers = maker.saucers  # one saucer, one explosion
        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._speed = 0
        self._player = None
        self._score_list = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
        self.initialized = False

That’s a lot of stuff. It makes me think we might need some different objects to help us, or something. I finally noticed the comment and fixed it. Dead giveaway that I pasted some code. We really only use the saucer image, the explosion is done separately. Let’s fix that up right now. I modify BitmapMaker:

        self.invader_explosion = self.make_and_scale_surface(invader_exploding, scale, (16, 8))
        self.invader_shot_explosion = self.make_and_scale_surface(invader_shot_explosion, scale, (6, 8))
        self.invaders = [self.make_and_scale_surface(invader, scale) for invader in invaders]
        self.player_shot = self.make_and_scale_surface(player_shot, scale, (1, 8))
        self.player_shot_explosion = self.make_and_scale_surface(player_shot_explosion, scale, (8, 8), "red")
        self.players = [self.make_and_scale_surface(player, scale, (16, 8),"green") for player in players]
        self.plungers = [self.make_and_scale_surface(plunger, scale, (3, 8)) for plunger in plungers]
        self.rollers = [self.make_and_scale_surface(plunger, scale, (3, 8)) for plunger in rollers]
        self.saucer = self.make_and_scale_surface(saucer, scale, (24, 8), "red")
        self.saucer_explosion = self.make_and_scale_surface(saucer_explosion, scale, (24, 8), "red")
        self.saucers = [self.make_and_scale_surface(saucer, scale, (24, 8), "red") for saucer in saucers]
        self.shield = self.make_and_scale_surface(shield, scale, (22, 16), "green")
        self.squiggles = [self.make_and_scale_surface(squig, scale, (3, 8)) for squig in squiggles]

I added the new self.saucer and self.saucer_explosion. There are two reference to the self.saucers and I fix each to ask for what it wants.

class InvadersSaucer(InvadersFlyer):
    def __init__(self, direction=1):
        self.direction = direction
        maker = BitmapMaker.instance()
        self._map = maker.saucer
        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._speed = 0
        self._player = None
        self._score_list = [100, 50, 50, 100, 150, 100, 100, 50, 300, 100, 100, 100, 50, 150, 100]
        self.initialized = False

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

That deserves a commit: remove saucers bitmap array, provide saucer and explosion.

Now what else can we do to clean this up?

Arrgh! I’ve broken the saucer entirely: it doesn’t run at all. Hm, a test has gone red as well.

I think the issue is that the update is running before we’re initialized. I was supposed to check that.

    def update(self, delta_time, fleets):
        if not self.initialized:
            return
        x = self.position.x + self._speed
        if x > self._right or x < self._left:
            self.die(fleets)
        else:
            self.position = (x, self.position.y)

That breaks more tests, presumably because they needed to initialize the saucer. What are they? Well one of them is this:

I thought I made that work. Let’s roll back. I still have one test failing, which means I committed on a red bar. Not good, Ron.

It’s the does run with 8 invaders one shown above. Does it just need the init_motion?

Yes, this gets me to green:

    def test_does_run_with_8_invaders(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.append(invader_fleet := InvaderFleet())
        invader_group = invader_fleet.invader_group
        assert invader_group.invader_count() == 55
        while invader_group.invader_count() > 8:
            invader_group.kill(invader_group.invaders[0])
        assert invader_group.invader_count() == 8
        fleets.append(saucer := InvadersSaucer())
        saucer.init_motion(0)
        assert fi.invader_saucers
        saucer.interact_with_invaderfleet(invader_fleet, fleets)
        saucer.update(1.0/60.0, fleets)
        assert fi.invader_saucers

However, what’s happening in the actual game? Saucers do not run.

I discover (again) that I need this:

    def update(self, delta_time, fleets):
        if not self.initialized:
            return
        x = self.position.x + self._speed
        if x > self._right or x < self._left:
            self.die(fleets)
        else:
            self.position = (x, self.position.y)

And that that breaks three tests. Let’s see what they need. The bug is that init_motion doesn’t set the flag, it’s set just before we call init_motion. Fix that.

    def end_interactions(self, fleets):
        if not self.initialized and self._player:
            shot_count = self._player.shot_count % 2
            self.init_motion(shot_count)

    def init_motion(self, shot_count):  
        self.initialized = True
        speed = 8
        if shot_count == 0:
            self._speed = -speed
            self.rect.center = Vector2(self._right, u.INVADER_SAUCER_Y)
        else:
            self._speed = speed
            self.rect.center = Vector2(self._left, u.INVADER_SAUCER_Y)

Green. That seems to fix things. Commit: fix bug where initialized flag was not set properly.

Summary

I’m tired and I have no Internet anyway.

This went fairly well, but as often happens with a flag sort of thing, the code got tricky and I introduced defects.

I rather wish that I had not committed along the way: the right thing would have been to pitch it and come up with a better idea. I do have a better idea now: a fake object. I’ll work on that next time and maybe by late Tuesday or Wednesday, you’ll see this and whatever else I do between now and then.

See you then … I hope!