Choose the Form of the Destructor
Python Asteroids+Invaders on GitHub
Today we’re going to resolve the issues with invaders getting low enough to end the game.
The issue we’re dealing with is handling the situation when the invaders move past the shields. I was thinking that we would create a Destructor that would kill the InvaderPlayer or RobotPlayer, with a nice explosion and all like that.
We could do that. However, that’s not exactly what’s supposed to happen. Invaders reaching the bottom of the screen is supposed to end the game. That changes things a bit. What do we need to happen when the invaders get too low?
- Whatever player or robot is active explodes;
- If there were players in reserve, they should be removed;
- The game transitions to Game Over if it isn’t already there.
I think this will be OK. Let’s just work through it. I am confident that we can get what we need.
Let’s use our new tdd
Live Template to drive out the Destructor. It will be very simple:
class Destructor:
def __init__(self):
pass
class TestDestructor:
def test_hookup(self):
assert 2 + 1 == 4
Hookup test fails as intended. Fix or remove, your choice.
def test_destructor_exits(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(Destructor())
assert fi.destructors
Even after I add the necessary boilerplate to FI and elsewhere, this test fails. I am surprised.
Something about imports. I move the Destructor out of the test file and the test runs as expected.
I am a bit upset by this confusion. Will proceed calmly to the next thing I planned to check:
def test_destructor_exits(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(destructor := Destructor())
assert fi.destructors
destructor.end_interactions(fleets)
assert not fi.destructors
This fails as expected. My intention here, by the way, is that the Destructor just appears for one cycle, cares about nothing, and removes itself. It’s the job of all the other guys to remove themselves. We’ll test that, too.
Here we just need this:
def end_interactions(self, fleets):
fleets.remove(self)
Green. Let’s commit: Initial Destructor.
Now let’s ensure that everyone who should remove themselves does. That will be InvaderPlayer, RobotPlayer, and, if I’m not mistaken, ReservePlayer.
def test_destructor_scares_right_things_away(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(InvaderPlayer())
fleets.append(ReservePlayer(1))
fleets.append(RobotPlayer())
assert fi.invader_players
assert fi.robots
assert fi.reserve_players
fleets.append(destructor := Destructor())
fleets.perform_interactions()
assert not fi.invader_players
assert not fi.robots
assert not fi.reserve_players
This seems right to me. It’s failing on not invader_players
.
class InvaderPlayer(Spritely, InvadersFlyer):
def interact_with_destructor(self, destructor, fleets):
fleets.remove(self)
Now fails on the robots. Same changes for the other two and we should be green. We are. Commit: destructor removes invader player, robot player, and reserve player.
Now what? Well, we want the Fleet to add a Destructor when the TOO_LOW is triggered, and we will need some explosions, probably.
Let’s see if there is a TOO_LOW test. Ah yes, this brings us to our two skipped tests from yesterday:
@pytest.mark.skip("not ready yet")
def test_fleet_removes_self_on_TOO_LOW(self):
fleets = Fleets()
fi = FI(fleets)
invaders = InvaderFleet()
fleets.append(invaders)
assert fi.invader_fleets
invaders.process_result(CycleStatus.TOO_LOW, fleets)
assert not fi.invader_fleets
@pytest.mark.skip("not ready yet")
def test_fleet_removes_player_on_TOO_LOW(self):
fleets = Fleets()
fi = FI(fleets)
invaders = InvaderFleet()
fleets.append(invaders)
fleets.append(player := InvaderPlayer())
assert fi.invader_players
invaders.begin_interactions(fleets)
invaders.interact_with_invaderplayer(fleets)
invaders.end_interactions(fleets)
Let’s start with those. The first one makes some sense. Let’s make it work and move forward.
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:
fleets.remove(self)
The test runs. Let’s have a new test that it adds a Destructor.
def test_fleet_adds_destructor_on_TOO_LOW(self):
fleets = Fleets()
fi = FI(fleets)
invaders = InvaderFleet()
fleets.append(invaders)
assert not fi.destructors
invaders.process_result(CycleStatus.TOO_LOW, fleets)
assert fi.destructors
Fails where expected. Fix:
elif result == CycleStatus.TOO_LOW:
fleets.remove(self)
fleets.append(Destructor())
At this point I really think we should observe the effect of this in the game. Not so much that I’m uncertain about it working as that I want to observe the effect.
I’m glad I looked. Everything works as I’d expect so far, but no new invaders appear. We removed the Fleet and didn’t add a new one. Note how the EMPTY branch works:
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:
fleets.remove(self)
fleets.append(Destructor())
We need something similar.
I’m not sure quite how to test this. Let me put it in and then we’ll see. I need to create a brand new fleet, because we know we’re going to attract mode.
elif result == CycleStatus.TOO_LOW:
fleets.remove(self)
fleets.append(Destructor())
capsule = TimeCapsule(2, InvaderFleet())
fleets.append(capsule)
That works as advertised. No explosion but a new rack shows up. (It’s not up higher because I have it starting low during testing.)
How deeply do we need to test this? Should we check for the time capsule? Should we check that it contains an InvaderFleet? Should we check that the InvaderFleet has a fresh generator?
Should we just let it be? Let’s at least look for a time capsule.
def test_fleet_adds_destructor_and_tc_on_TOO_LOW(self):
fleets = Fleets()
fi = FI(fleets)
invaders = InvaderFleet()
fleets.append(invaders)
assert not fi.destructors
invaders.process_result(CycleStatus.TOO_LOW, fleets)
assert fi.destructors
assert fi.time_capsules
Now then. We would like for the players to add explosions.
def test_player_explodes(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(InvaderPlayer())
fleets.append(destructor := Destructor())
fleets.perform_interactions()
assert not fi.invader_players
assert fi.invader_explosions
Fails as expected. Change:
class InvaderPlayer(Spritely, InvadersFlyer):
def interact_with_destructor(self, destructor, fleets):
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)
We had a convenient method already, just the thing how nice.
An interruption occurred …
OK, it’s about 2 1/2 hours since I wrote “how nice”. Where was I? Ah yes explosions. We’d like for the RobotPlayer to issue an explosion. Replicate the test:
def test_robot_explodes(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(RobotPlayer())
fleets.append(destructor := Destructor())
fleets.perform_interactions()
assert not fi.robots
assert fi.invader_explosions
Fails on the last assert, as expected. In the RobotPlayer:
def interact_with_destructor(self, destructor, fleets):
fleets.remove(self)
fleets.append(PlayerExplosion(self.position))
But we also have, in that class:
def interact_with_invadershot(self, shot, fleets):
if self.colliding(shot):
fleets.remove(self)
fleets.append(PlayerExplosion(self.position))
Extract:
def interact_with_destructor(self, destructor, fleets):
self.explode(fleets)
def interact_with_invadershot(self, shot, fleets):
if self.colliding(shot):
self.explode(fleets)
def explode(self, fleets):
fleets.remove(self)
fleets.append(PlayerExplosion(self.position))
We should be committing this stuff. Commit: player and robot explode when invaders get TOO_LOW.
I think I’d like the reserves to explode as well. I patch it in to see if I like it, and I do.
class ReservePlayer(InvadersFlyer):
def interact_with_destructor(self, destructor, fleets):
fleets.remove(self)
fleets.append(PlayerExplosion(self.rect.center))
Make that official with a test:
def test_reserve_explodes(self):
fleets = Fleets()
fi = FI(fleets)
fleets.append(ReservePlayer(1))
fleets.append(destructor := Destructor())
fleets.perform_interactions()
assert not fi.robots
assert fi.invader_explosions
Passes. Commit: ReservePlayers automatically explode if invaders get TOO_LOW.
And I think we’re done. Let’s reflect.
Reflection
Welp. We set out to make the game end when the invaders get too low. We managed to do it all with tests, although I did spike the final bit, blowing up the reserves, before writing that test. It looks rather good:
We can debate about sounds: neither the reserves nor the robot make a sound when they explode. Might be just as well.
There’s one more change we can make, removing the unused coin for invaders attract mode. Done, commit: remove unused coin.
This went very smoothly, as I predicted yesterday. What made the difference? I’d have to give a lot of credit to the idea of using the Destructor object. It worked just like our little independent objects are supposed to: detecting it in the various InvaderPlayer, RobotPlayer. and ReservePlayer objects allowed for “action at a distance” in a standard and very clean fashion.
I do think that yesterday’s experimentation also had value in that it focused my mind on the need for a more general solution rather than a quick patch, and that was the precursor to the Destructor. Would it have been better to have thought of it yesterday? Sure. And it would have been better to have bought Apple stock a million years ago. I am not allowed to go back on my own timeline to buy that stock, so I’m surely not going back to have the Destructor idea first thing yesterday. Wouldn’t be prudent.
We have a nice new feature and the code for it is both tested and rather well factored.
There is some interesting duplication … there is similarity among InvaderPlayer, RobotPlayer, and ReservePlayer. Perhaps we should think about trying to remove that duplication, to see what it leads to. Hopefully it would lead to something better than subclassing … but even that might be interesting.
Perhaps. Come by next time and see what we do!