Odd Morning ...
Python Asteroids+Invaders on GitHub
A report on a PyCharm Live Template for starting TDD. Some experimentation. No yippee.
The Invaders game is essentially done. One thing that it does not have is a two-player mode. Other notions written on cards include:
- Make Robot explode at bottom of game;
- Does Player explode at bottom of game?
- Some kind of PyCharm macro to set up a simple test file.
What about this one?
- Some kind of PyCharm macro to set up a simple test file.
I actually did that, although I have not yet written about it.
Suppose we needed a test for class Mangle. I can create a new Python file, test_mangle.py and then type: tdd and Tab, and I get this:
The definition of that “live template” is pretty simple, and looks like this:
import pytest
class $TEST_CLASS_NAME$:
def __init__(self):
pass
class Test$TEST_CLASS_NAME$:
def test_hookup(self):
assert 2 + 1 == 4
I’m sure there’s a lot more that PyCharm’s templates can do, but this is my first. Curiously, since I created it, I have not had the need to create a new test file, but I’m ready.
Once there is a place to put a test, I’m fairly good about doing them. This will help just a bit with that first step.
So we can cross that item off the list.
What about explosions when the invaders get too low? I don’t think we’ve ever made that happen.
I wonder where that’s handled.
We have this value in u.py
:
INVADER_TOO_FAR_DOWN_Y = SHIELD_Y
That’s used here:
class InvaderGroup:
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)
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
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)
I really did search it out that way, using my recollection that there was a constant, and following PyCharm’s Command+B to find references. Nice, PyCharm!
It’s pretty clear there at the bottom that we just fling a game_over coin into the hopper, which immediately transitions to attract mode. This is the only use of that coin, by the way, because our other player and robot deaths are handled differently:
class RobotPlayer(Spritely, InvadersFlyer):
def interact_with_invadershot(self, shot, fleets):
if self.colliding(shot):
fleets.remove(self)
fleets.append(PlayerExplosion(self.position))
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)
The InvaderPlayer does have another interesting method:
def hit_invader(self, invader, fleets):
self.hit_by_something(fleets)
Because invaders are not running in the mix, there is special code to see if they are colliding, and in the case of the player, we have this:
class Invader(Spritely):
def interact_with_invaderplayer(self, player, fleets):
if self.colliding(player):
player.hit_invader(self, fleets)
That looks superficially the same as any other interacting object, but it is not, because there is no corresponding call to the other, such as player.interact_with_invader(...)
. So we send the hit message directly.
- Note
- I start experimenting here, but I freely grant that I was expecting that a simple change or two would solve the problem. As we’ll see, that does not happen.
Let’s try this …
What if we added an interaction with the robot here, and ignored the TOO_LOW message entirely. I think that the game would continue until an invader actually strikes the player or robot. Let’s pass on the TOO_LOW: this should already work for the player. Putting pass
into the TOO_LOW case does not work: the invaders just stall not moving at all.
OK, how about this …
Maybe we want to do the same on TOO_LOW as we do on REVERSE?
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:
self.reverse_travel()
# from core import coin
# coin.invaders_game_over(fleets)
This, too, does not work. The invaders just start marching straight down the screen once they get too low. I think we need to look back at the creation of the cycle status. First let’s roll these experiments back.
Now what really goes on here?
class InvaderGroup:
def update_next(self, origin):
return self.perform_update_step(origin)
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 move_one_invader(self, origin):
invader = self.next_invader()
invader.move_relative_to(origin)
self._next_invader += 1
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
Ah. We cannot just reverse travel on too low, it keeps reversing back and forth forever.
Let’s try this other thing …
Maybe we shouldn’t send that TOO_LOW response at all:
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
Well, almost. Still not very promising. this time I notice:
- It takes forever for them to get that low.
- If you shoot a suitable gap in the invader fleet, you could stay in the gap forever.
- Even if they do destroy your player, they never stop marching down.
- Note
- Here, after three seprate unsuccessful whacks with the hammer, I see that there is apparently no quick and easy change. We’ll have to do something more substantial than a quick change.
Enough experimentation. Roll back again and let’s see what we can figure out.
When the invaders get too low, what do we want?
- The existing player or robot explodes;
- A new rack of invaders appears;
- A new player or new robot appears, as appropriate: player if there’s one left, robot otherwise.
If we were to do something on TOO_LOW similar to what we do on EMPTY, that should start a new rack. The current code is this:
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)
We should certainly remove(self)
. And if we were to send hit_invader
to the current player, that would detstroy the player as desired. And we could put the same method on the robot.
Do we have any tests for these cycle things? We do have some. Let’s do some more.
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
This passes the first assert, fails the second.
Fix that:
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)
Now let’s review the player’s hit_invader
to remind ourselves what it does, so that we can figure out a test:
def hit_invader(self, invader, 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)
So, what will we need? We’ll need the InvaderFleet to detect a player or robot and if it finds one, when it is too low, send hit_by_something
to it. So we’ll need an interaction test.
Arrgh. I get this much written:
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)
The issue is this: update is done before interactions. Therefore the InvaderFleet cannot know whether there is an InvaderPlayer or RobotPlayer present. Therefore it cannot send it a message.
What can we do?
One possibility is to create a new kind of Flyer that destroys any player or robot that sees it. Then the InvaderFleet, when it gets too low, could remove itself and plant a PlayerDestructionDevice, which would, on the next cycle, explode the player or robot and all would be well.
It sounds so simple when you put it that way. We should do that. But not this morning. We’ve been here two hours and that is long enough for one session. Let’s sum up: It’s not all bad.
Summary
The first issue is what to do with the current changes. I like the new test, but it is not suitable for prime time. I could do something clever, save the test, mark it ignored, throw the rest away. Let’s do that.
@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)
Commit those, roll back any other changes.
So, was this wasted effort? I think not: the three experiments explored easy fixes to the issue and convinced me that there may not be one. I won’t nail down that conclusion until next session: I might get a better idea.
The existing idea, a PlayerDestructionDevice, has some art to it, in that it is the sort of thing one does in this design: change the mix and let the existing objects sort it all out. I think it will work nicely.
And it’ll really be a PlayerOrRobotDestructionDevice, so it wil be useful in two similar but different situations.
That said, creating a new flyer is a bit of a big deal, even though this one will do nothing but exist, and remove itself after it knows it has been seen.
I believe that when we do this, probably tomorrow, it will go quickly and be quite satisfying. Had I set out this morning to experiment with solutions to this problem, I’d probably feel totally good about it all. As it stands, I really kind of thought a simple solution could be found, so I am a bit disappointed, either that there seems to be no such thing … or that I mistakenly thought that there was.
So … this morning I didn’t get quite the kick of “Geek Joy” that I like to get. A decent outcome … just no little yippee!!
We’ll get one next time, you betcha. See you then!