Game Over II
Python Asteroids+Invaders on GitHub
Let’s at least deal with the invaders falling off the bottom. Maybe a first step toward a better attract mode. Pretty ragged.
It’s still early AM and I need to do something to keep my mind on track. This morning’s quick implementation of GameOver allows the invaders to crawl down to the bottom of the screen and beyond. Let’s do something better while the game is over.
This is one of those odd things that hits you. We have a nicely playable game, not perfect but nicely wrapped up overall, and now we want to add in a completely new kind of situation, doing something special while the game is actually over. A sort of special side act, with no real attachment to the main attraction.
We sit down with our product development hat on and think about what we need and how much we need it.
-
Don’t run the invaders off the bottom of the screen. It just looks tacky. It would suffice to restart them back up at the top once in a while. Something better would be nice.
-
Note that the game is supposed to be over when the invaders get to the bottom of the screen. Currently we finesse that by just letting them eat any remaining players, which ends the game. We might deal with that and this problem as well.
-
What if “game over”, instead of letting the fleet continue, started a new fleet? Then when the fleet got too low, we could just do another game over and it would loop.
-
Is there some way we could put in a sort of fake player that would roam around shooting? A start at our “AI” fantasy?
-
The best way to start a new fleet ,or any game layout, is with a specialized coin. Maybe instead of inserting the GameOver directly, we should create an invaders game over coin.
I like the idea of removing existing invaders and creating a new fleet of them on GameOver. Then if they do reach the bottom before someone comes up with a quarter, that would trigger a new GameOver. And the coin thing is definitely a good idea.
We’ll start there:
class PlayerMaker(InvadersFlyer):
def reserve_absent_game_over(self, fleets):
fleets.remove(self)
fleets.append(InvadersGameOver())
I am working without tests here. This is not ideal. I have no real excuse. I’m just not feeling up to it.
Are there PlayerMaker tests? Let’s see.
def test_captures_reserve(self):
maker = PlayerMaker()
maker.interact_with_reserveplayer(ReservePlayer(1), None)
maker.interact_with_reserveplayer(correct := ReservePlayer(2), None)
maker.interact_with_reserveplayer(ReservePlayer(0), None)
assert maker.reserve == correct
maker.begin_interactions(None)
assert maker.reserve.reserve_number < 0
def test_notices_player(self):
maker = PlayerMaker()
maker.begin_interactions(None)
final = maker.pluggable_final_action
maker.interact_with_invaderplayer(None, None)
assert maker.pluggable_final_action is not final
def test_rezzes_time_capsule(self):
maker = PlayerMaker()
maker.begin_interactions(None)
maker.interact_with_reserveplayer(ReservePlayer(0), None)
maker.end_interactions(fleets := FakeFleets())
assert fleets.appends
capsule = fleets.appends[0]
assert isinstance(capsule.to_add, InvaderPlayer)
assert isinstance(capsule.to_remove, ReservePlayer)
These tests are just sending interaction messages and checking results. Let’s see if we can test an end of game situation that way:
def test_game_over(self):
maker = PlayerMaker()
maker.begin_interactions(None)
maker.end_interactions(fleets := FakeFleets())
assert fleets.appends
game_over = fleets.appends[0]
assert isinstance(game_over, InvadersGameOver)
That test runs.
- Note
- Did you notice that telling you about my feeling that I just couldn’t deal with test gave me enough gumption to at least look for tests, and then try one? Did I just trick myself psychologically?
OK, what will our new reserve_absent_game_over
method do? It needs to remove the PlayerMaker, and issue a coin that creates the GameOver and leaves everything else alone. (Or it could just stay the way it is, and we could do this some other way.)
Be that as it may, lets look at whether we can detect that the invaders are too low and end the game.
The InvaderFleet handles a number of end conditions already, which are returned from the InvaderGroup:
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)
Suppose there was a CycleStatus.TOO_LOW
, signifying that the invaders were, well, too low, and that status also took us to the GameOver state?
How could the InvaderGoup know that?
def perform_update_step(self, origin):
if self._next_invader < len(self.invaders):
self.move_one_invader(origin)
return CycleStatus.CONTINUE
else:
return self.end_cycle()
def end_cycle(self):
self._next_invader = 0
if self.any_out_of_bounds():
return CycleStatus.REVERSE
elif len(self.invaders) > 0:
return CycleStatus.NEW_CYCLE
else:
return CycleStatus.EMPTY
def any_out_of_bounds(self):
left = u.BUMPER_LEFT + u.INVADER_HALF_WIDTH
right = u.BUMPER_RIGHT - u.INVADER_HALF_WIDTH
colliding = [invader.is_out_of_bounds(left, right) for invader in self.invaders]
return any(colliding)
Note that we just explicitly test all the invaders for out of bounds. I think in some past version, we probably had a flag that indicated that someone had hit the boundary. This is more direct.
We have lots of tests for InvaderGroup. I think we want to drive out an additional case in end_cycle
, checking self_any_too_low
or something like that and returning the new CycleStatus.TOO_LOW
.
We have two tests that seem somewhat relevant:
def test_reversal(self):
group = InvaderGroup()
bumper = Bumper(u.BUMPER_RIGHT, +1)
invader = group.invaders[0]
_pos_x, pos_y = invader.position
invader.position = (u.BUMPER_RIGHT, pos_y)
result = group.end_cycle()
assert result == CycleStatus.REVERSE
invader.position = (u.BUMPER_LEFT, pos_y)
assert result == CycleStatus.REVERSE
def test_invader_kills_player(self):
maker = BitmapMaker.instance()
sprite = Sprite(maker.invaders)
invader = Invader(1, 1, sprite)
player = InvaderPlayer()
fleets = FakeFleets()
invader.interact_with_invaderplayer(player, fleets)
assert not fleets.removes
invader.position = player.position
invader.interact_with_invaderplayer(player, fleets)
assert fleets.removes
assert player in fleets.removes
The first one is similar to what we’ll need. The second will become irrelevant after we do our new feature.
Let me try to write this test. First, commit the one I just did: new test for game over. I think we’ll see that change, but it’s good for now and worth committing.
Now:
def test_too_low(self):
group = InvaderGroup()
invader = group.invaders[0]
_pos_x, pos_y = invader.position
invader.position = (_pos_x, u.INVADER_TOO_LOW_Y)
result = group.end_cycle()
assert result == CycleStatus.TOO_LOW
I just decided to have the value be “well known” and hand calculate it.
class CycleStatus(Enum):
CONTINUE = "continue"
NEW_CYCLE = "new cycle"
REVERSE = "reverse"
EMPTY = "empty"
TOO_LOW = "too low"
u.py
INVADER_DOWN_STEP_Y = 32
class InvaderFleet(InvadersFlyer):
step = Vector2(8, 0)
down_step = Vector2(0, u.INVADER_DOWN_STEP_Y)
INVADER_TOO_LOW_Y = SHIELD_Y - INVADER_DOWN_STEP_Y
The test is still failing, which does not surprise me. But it seems to be returning a CycleStatus.REVERSE
, which does surprise me. I think it’s because I have not initialized the invader positions.
def test_too_low(self):
group = InvaderGroup()
group.position_all_invaders(Vector2(u.SCREEN_SIZE / 2 - 5 * 64, 0x78))
invader = group.invaders[0]
_pos_x, pos_y = invader.position
invader.position = (_pos_x, u.INVADER_TOO_LOW_Y)
result = group.end_cycle()
assert result == CycleStatus.TOO_LOW
Now it’s returning NEW_CYCLE
which I do expect. Now to put in the new check:
def end_cycle(self):
self._next_invader = 0
if self.any_out_of_bounds():
return CycleStatus.REVERSE
elif self.any_below_limit():
return CycleStatus.TOO_LOW
elif len(self.invaders) > 0:
return CycleStatus.NEW_CYCLE
else:
return CycleStatus.EMPTY
def any_below_limit(self):
too_low = [invader.y <= u.INVADER_TOO_LOW_Y for invader in self.invaders]
return any(too_low)
I expected this to make the test run. I see four tests failing. Should I revert? Not yet.
def any_below_limit(self):
too_low = [invader.position.y <= u.INVADER_TOO_LOW_Y for invader in self.invaders]
return any(too_low)
Now test_too_low
is passing and three others are returning too low
. I bet they need to be initialized also.
My mistake is that the screen is upside down and I have not accommodated that.
INVADER_DOWN_STEP_Y = 32
INVADER_TOO_FAR_DOWN_Y = SHIELD_Y + INVADER_DOWN_STEP_Y
def any_below_limit(self):
too_low = [invader.position.y >= u.INVADER_TOO_FAR_DOWN_Y for invader in self.invaders]
return any(too_low)
Tests are all green. I need to check to see this works in the game, but so far, it does not. We know that we are getting the condition back, but we don’t deal with it. As a quick check:
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.slug(fleets)
This does send us to the Asteroids game over when the fleet gets too low. It also makes me think that my lowest starting position is too low, so I make a card to triple-check that.
So I know that this works. Now I want a new coin, like the invaders coin but without players.
coin.py
def invaders(fleets):
fleets.clear()
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(PlayerMaker())
fleets.append(ShotController())
fleets.append(InvaderScoreKeeper())
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))
Well now. That’s intricate, isn’t it? Still, game setup is inherently intricate: we have to get all the objects in place somehow. Emulating that code, I do the new coin:
def invaders_game_over(fleets):
fleets.clear()
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(InvaderScoreKeeper())
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))
But it has lost the old score, which we would like to retain. Let me resort to egregious hackery:
def invaders_game_over(fleets):
# egregious hackery
keeper = InvaderScoreKeeper()
for flyer in fleets.all_objects:
if isinstance(flyer, InvaderScoreKeeper):
keeper = flyer
fleets.clear()
fleets.append(keeper)
# end egregious hackery
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))
I just grab and save the old keeper. Also I remember to toss in the GameOver object. And I find a typo in a method:
def interact_with(self, other, fleets):
other.interact_with_invadersgameover(self, fleets)
I left out the underbar after with
. I do not know why that didn’t fail before. I want to know, but I am too raggedy to find out.
I tweak the check for too low to allow the invaders to completely clear the shields:
def any_below_limit(self):
too_low = [invader.position.y > u.INVADER_TOO_FAR_DOWN_Y for invader in self.invaders]
return any(too_low)
We may wish to do more tuning here.
One more thing, change the regular GameOver to use the new coin.
def reserve_absent_game_over(self, fleets):
fleets.remove(self)
coin.invaders_game_over(fleets)
All is well (ish):
Summary
This felt very ragged, and the result doesn’t feel quite right. I think it is close enough for a second version of Game Over, and will commit: invaders too low now triggers game over. game over uses a new coin.
Let me pause here to give myself a little credit. I did do some testing even though I really didn’t want to. Since we allow working without tests here chez Ron, and just feel badly about it, I get to feel a bit less badly because I did at least some fairly decent testing.
What makes me feel “not quite right”?
Well, there are no tests for the coins, including our new one, and there is no test that actually shows that we issue the coins in the cases where we want the game to be over, so all that had to be tested by looking at the game. Since the Game Over screen is complicated, it could easily be wrong without our noticing.
The handling of the scorekeeper is downright nasty, but I don’t see a much better way to do it. We could perhaps push a new method into Fleets but we like to resist changing the core. We could have the keeper be a parameter to the new coin, which would also be up in core, but then we could capture the keeper during interactions, in our usual fashion.
Mostly, I just didn’t feel right. I think the holidays are getting me into a weird state or something.
I would grade this session as a “gentleman’s C”, which is not where I like to operate, but is, I’ll argue, barely acceptable, at least for now.
But It just doesn’t feel right to me. Fail again next time. Fail better.1
See you next time!
-
Paraphrasing Samuel Beckett, who probably was not as optimistic as even that quote suggests. ↩