Python Asteroids+Invaders on GitHub

When the invaders fire on the robot, it does not explode. When it gets a new batch of invaders, they don’t fire on it. This cannot endure. Results: good. Code: OK.

I think the failure to fire is due to the setup of the ShotController, which, if memory serves, has an initial phase where it waits for a Player to appear. Since the Robot is not a Player per se, the ShotController won’t start firing. That should be easy enough to fix. I am concerned about the timing for destroying the Robot when we hit it, but probably everything will be OK. We’ll find out.

Firing at the Robot

A quick look at ShotController makes me doubt my assumption above, the one that “memory serves”. The problem occurs when the invaders get to the bottom of the screen. I have not been able to reproduce the robot wiping out a whole fleet. When the invaders get too low, the fleet triggers a coin:

class InvaderFleet(InvadersFlyer):
    def process_result(self, result, fleets):
        if result == CycleStatus.CONTINUE:
            pass
        elif result == CycleStatus.NEW_CYCLE:
            self.step_origin()
        elif result == CycleStatus.REVERSE:
            self.reverse_travel()
        elif result == CycleStatus.EMPTY:
            fleets.remove(self)
            capsule = TimeCapsule(2, self.next_fleet())
            fleets.append(capsule)
        elif result == CycleStatus.TOO_LOW:
            from core import coin
            coin.invaders_game_over(fleets)


def invaders_game_over(fleets):
    keeper = InvaderScoreKeeper()
    for flyer in fleets.all_objects:
        if isinstance(flyer, InvaderScoreKeeper):
            keeper = flyer
    fleets.clear()
    fleets.append(keeper)
    fleets.append(RobotPlayer())
    fleets.append(InvadersGameOver())
    left_bumper = u.BUMPER_LEFT
    fleets.append(Bumper(left_bumper, -1))
    fleets.append(Bumper(u.BUMPER_RIGHT, +1))
    fleets.append(TopBumper())
    fleets.append(InvaderFleet())
    fleets.append(RoadFurniture.bottom_line())
    fleets.append(TimeCapsule(10, InvadersSaucerMaker()))
    for i in range(3):
        fleets.append(ReservePlayer(i))
    half_width = 88 / 2
    spacing = 198
    step = 180
    for i in range(4):
        place = Vector2(half_width + spacing + i * step, u.SHIELD_Y)
        fleets.append(RoadFurniture.shield(place))

Adding a ShotController fixes the problem at least for now. Commit: add missing ShotController to invaders_game_over coin.

Reflection

It seems to me that I could find a book, perhaps even one where I’m one of the authors, that says that when we find a defect, we should write a test that shows the defect, fix the defect, see the test run.

In the case of testing the invaders_game_over method above, what would such a test look like? I guess it would create a Fleets object, pass it to invaders_game_over and then troll through looking for each of the objects that the method inserts. It should probably also check to be sure there are no other objects than the ones it looks for, which would make the test break if someone added in a ShotController and didn’t fix up the test.

I’m not able to make myself want to do that test. It seems tedious and silly. If I forgot ShotController, the test would pass. If I remember, the test would briefly fail, then pass. The test, at best, protects me from removing something from the coin … and I’m not likely to do that by accident, so that when the test fails after a removal, I’d just fix the test.

Please toot at me if you disagree, and make your argument, but I’m not going to do that test: I don’t see how it gives me any value.

Robot Damage

Let’s move on to the robot damage. We’ll start with a look at how the Player takes damage:

class InvaderPlayer(Spritely, InvadersFlyer):
    def interact_with_invadershot(self, shot, fleets):
        if self.colliding(shot):
            self.hit_by_something(fleets)

    def hit_by_something(self, fleets):
        frac = self.x_fraction()
        player.play_stereo("explosion", frac)
        fleets.append(PlayerExplosion(self.position))
        fleets.remove(self)

Let’s patch in something similar. No, let’s see if we can test it in.

Note
One somewhat good argument for writing the coin test is that it gets us in the frame of testing, while rationalizing not doing it puts us in the frame of not testing, and we really don’t profit from being in that frame.

Now it happens that there is no test for the Player being hit by a shot. So that’s interesting, isn’t it? We’ll do the one for the robot and then think about the player.

    def test_hit_by_shot(self):
        fleets = Fleets()
        fi = FI(fleets)
        pos = (500, u.INVADER_PLAYER_Y)
        robot = RobotPlayer()
        robot.position = pos
        shot = InvaderShot(pos, Sprite.squiggles)
        robot.interact_with_invadershot(shot, fleets)
        assert not fi.robots

That seems like a good start. In robotPlayer:

class RobotPlayer(Spritely, InvadersFlyer):
    def interact_with_invadershot(self, shot, fleets):
        if self.colliding(shot):
            fleets.remove(self)
            fleets.append(PlayerExplosion(self.position))

I expect my test to run. The RobotPlayer gets this error setting the InvaderShot position:

    @position.setter
    def position(self, vector):
>       self.sprite.position = vector
E       AttributeError: 'method' object has no attribute 'position'

The reference to squiggles should squiggles(). Green. I want to see what this does in the game. What happens is that the robot does explode, silently, because we didn’t play the sound … but it never comes back.

The issue, I think, is that the PlayerMaker removes itself when it decides the game is over. I think it should not do that. However, if it does not remove itself, we get an infinite number of Robots.

Some hammering gets me these changes:

class PlayerMaker(InvadersFlyer):
    def interact_with_robotplayer(self, bumper, fleets):
        self.pluggable_reserve_action = self.final_do_nothing
        self.pluggable_final_action = self.final_do_nothing

    def reserve_absent_game_over(self, fleets):
        fleets.remove(self)
        fleets.append(InvadersGameOver())
        robot = RobotPlayer()
        capsule = TimeCapsule(2.0, robot)
        fleets.append(capsule)
        maker = PlayerMaker()
        maker_capsule = TimeCapsule(2.1, maker)
        fleets.append(maker_capsule)

Here we note that there is a robot and if there is, set up to exit player maker doing nothing. When we do the reseerv_absent_game_over method, we add in the robot, but we also add in a new maker. We have to remove the existing one, because two seconds are going to go by and it will keep detecting absence of players and tossing in a new one.

Despite that I liked this a while back, I don’t like it as much, especially since we’ve added the robot case to it.

And I only just now noticed that the robot scores points. The idea, I thought, was to leave the player’s score there. The issue is that there is still a scorekeeper in the game.

A related issue is that we’ll still be tossing InvaderScore objects in when invaders get hit by robot shots:

class Invader(Spritely):
    def interact_with_group_and_playershot(self, shot, group, fleets):
        if self.colliding(shot):
            player.play_stereo("invaderkilled", self.x_fraction())
            shot.hit_invader(self, fleets)
            group.kill(self)
            fleets.append(InvaderScore(self._score))
            fleets.append(InvaderExplosion(self.position))

If we remove the ScoreKeeper, the Scores will never be removed.

class InvaderScore(InvadersFlyer):
    def interact_with_invaderscorekeeper(self, keeper, fleets):
        fleets.remove(self)

Ultimately, if the game runs on long enough, memory will fill with Score instance, and that will be bad.

Slow Down. Think!

We’d best slow down and think a bit more carefully. What are some options:

  • Display the player’s score some other way and let the robot accrue its own score. Arguably that is interesting.
  • Give the robot its own kind of shot that does not score. That would require changes to invader fleet, group, and invader class itself, to field the new interaction.
  • Fix scorekeeper to be aware that there is a robot on screen, and simply fail to accrue scores in that case.
  • Create a different kind of scorekeeper for robots, remove the real one, insert the fake one at the right times.

If these, glitching the scorekeeper seems most fruitful … but if the scorekeeper scores immediately it might not be aware of the robot. And …

class InvaderScoreKeeper(InvadersFlyer):
    def interact_with_invaderscore(self, score, fleets):
        self.total_score += score.score

So that won’t do, will it? We could cache the score and install it on end_interactions if no robots have shown up.

We do have a test for score accumulation that we could modify:

    def test_accumulates(self):
        score = InvaderScore(100)
        keeper = InvaderScoreKeeper()
        assert keeper.total_score == 0
        score.interact_with(keeper, [])
        assert keeper.total_score == 100
        score.interact_with(keeper, [])
        assert keeper.total_score == 200

For this new scheme to work, we’ll need the keeper to see begin_interactions, interact_with_score, and end_interactions. Change the test to have them:

    def test_accumulates(self):
        score = InvaderScore(100)
        keeper = InvaderScoreKeeper()
        assert keeper.total_score == 0
        keeper.begin_interactions([])
        score.interact_with(keeper, [])
        keeper.end_interactions([])
        assert keeper.total_score == 100
        keeper.begin_interactions([])
        score.interact_with(keeper, [])
        keeper.end_interactions([])
        assert keeper.total_score == 200

Now we change the test so the accrual doesn’t happen until end:

    def test_accumulates_only_at_end(self):
        score = InvaderScore(100)
        keeper = InvaderScoreKeeper()
        assert keeper.total_score == 0

        keeper.begin_interactions([])
        score.interact_with(keeper, [])
        assert keeper.total_score == 0
        keeper.end_interactions([])
        assert keeper.total_score == 100

        keeper.begin_interactions([])
        score.interact_with(keeper, [])
        assert keeper.total_score == 100
        keeper.end_interactions([])
        assert keeper.total_score == 200

This fails. Fix it:

    def begin_interactions(self, fleets):
        self._cycle_score = 0

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

    def interact_with_invaderscore(self, score, fleets):
        self._cycle_score += score.score

    def end_interactions(self, fleets):
        self.total_score += self._cycle_score

I think we should test for more than one score in a cycle, although the probability of that happening approaches zero from the left.

    def test_can_accumulate_more_than_one(self):
        score = InvaderScore(100)
        keeper = InvaderScoreKeeper()
        assert keeper.total_score == 0
        keeper.begin_interactions([])
        score.interact_with(keeper, [])  # one score
        score.interact_with(keeper, [])  # another score
        assert keeper.total_score == 0
        keeper.end_interactions([])
        assert keeper.total_score == 200

Now we can write a test for not scoring robot hits:

    def test_robot_cannot_score(self):
        score = InvaderScore(100)
        keeper = InvaderScoreKeeper()
        assert keeper.total_score == 0

        keeper.begin_interactions([])
        score.interact_with(keeper, [])
        assert keeper.total_score == 0
        keeper.interact_with_robotplayer(None, [])
        keeper.end_interactions([])
        assert keeper.total_score == 0

This fails, as I expected. Now in the keeper:

    def begin_interactions(self, fleets):
        self._cycle_score = 0
        self._saw_robot = False

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

    def interact_with_invaderscore(self, score, fleets):
        self._cycle_score += score.score

    def interact_with_robotplayer(self, bumper, fleets):
        self._saw_robot = True

    def end_interactions(self, fleets):
        if not self._saw_robot:
            self.total_score += self._cycle_score

Test passes. Try game. Game works as advertised, video to follow. Let’s commit, we’re way overdue.

Commit: Invaders now shoot at robot after reaching bottom of screen.Robot explodes when hit. Robot hits do not score points. Sorry about so many things in one commit.

Summary

I think this was a bit ragged, catch-as-catch-can. We did do some good tests for most of what happened, and while something better might be done about initial game loads, I don’t think a matching test is quite the thing.

The glitches needed to make the robot player work, get hit, not score, get restarted correctly, and so on, feel tacked on to me. That is because they are kind of tacked on. The scoring glitch, don’t score at the last minute if there’s a robot in the room … that looks a lot like a bit of orange crate nailed over a hole in the siding.

But suppose we had written this program in a more conventional fashion, where we were operating and colliding all the objects in a big loop, and then one day we needed to put in an attract mode with a fake player / robot. We’d very likely be sprinkling if attact_mode all around. I don’t think this is notably more hacked together … but it is hacked together.

When the last player is hit, we basically just toss a robot into the mix. On its own, that’s rather nifty. It’s the say that other objects had do shift and lean to accommodate the robot that doesn’t seem quite right. At this moment, I don’t see a much better way to do these things. If one comes to your mind, let me know.

If one comes to my mind, from within or without, we’ll take a look at it.

For now … we have an improved attract mode and some additional sensible tests. It’s a good result, even if there is a bit of orange crate in a couple of spots.

See you next time!