P-226 - Aimed Shots
Python Asteroids+Invaders on GitHub
If I’m right, which assumes facts not in evidence, this next step is going to be surprisingly easy. This often happens. There may be a reason.
Here’s the remains of the plan for invader shots:
-
Alternate among the three types of shots; -
No more than three shots alive; -
Each type can occur only once on the screen at the same time; -
Make shots emanate from specific columns; -
Make them emanate from the bottom of the column; -
Improve the animation of the player when hit. - Pick ideal column for targeted rolling shot.
I saved the targeting for last, because it seemed to me to be the most difficult of the things we had to do. In fact it seemed to me to be the only difficult item, the only one where I didn’t really have a good idea about how to do it.
- Alternate among shots? Sure, keep track somehow. We used a list.
- No more than 3? Dropped out of using a list.
- One of each type at a time? Dropped out of using a list.
- Specific columns? Easy enough, we had a way to spot the bottom of a column.
- Player animation? Separate and a solved problem for other objects.
All of the items above seemed straightforward before I had done any of them, and as I did each one, the next one’s implementation seemed to be a reasonable extension of the one that went before. I did make some mistakes along the way. I just do that to entertain you. But the items were all pretty easy.
What about targeting? I really didn’t know what would have to be done. I just thought it must be difficult. But now I don’t think it’s difficult and I think I know why. Let’s begin by doing it.
Targeting Rolling Shot
Two of the three shots, “squiggles” and “plunger”, drop from “random” columns, following a simple pattern. We have copied that pattern to the best of our ability, more for historical reasons than any other.
The third shot type, “rolling”, is supposed to fall directly over the player whenever that is possible. The invaders move, the player moves, I didn’t see how to do that. Now, I think that I do.
The player has some X coordinate. We can find that out by interacting the InvaderFleet with the player and somehow getting that info down to the controller. The rack of aliens has a starting position, origin
, known to the InvaderFleet and used to position all the invaders. The invader X position is INVADER_SPACING*column
, which is the invader center. So we want to drop the targeted shot from the column where INVADER_SPACING*column
is nearest to the player X.
That seems nearly trivial. The most difficult part will be getting that information down to the ShotController. But wait! ShotController is a flyer! It is in a position to know everything it needs to know using our standard interaction scheme.
I think we have a plan. Let’s see about writing some targeting tests for the controller.
TDD Targeting
I could code this up. So could you, if you’d been working in this code base for a bit. But I want to test-drive it, because there are a few interesting cases we might want to consider, and because things go better when I test drive.
As soon as I write this much, I start thinking about what I’d like to be able to say:
def test_targeting(self):
I think I’d like to take the player x coordinate and the fleet origin, and ask for the column. Anything else just seems like too much. So let’s do that in our test. We’ll worry separately about how we might get that information into the controller. And we’ll probably even test it.
So I do this in the test:
def test_targeting(self):
controller = ShotController()
player_x = 100
fleet_x = 0
# 0 64 128 192 256
assert controller.target_column(player_x, fleet_x) == 2
I made myself a little cheat sheet there. Now we need the method. I try this:
def target_column(self, player_x, fleet_x):
steps = (player_x - fleet_x) / 64
return round(steps)
This test is certainly passing. Let’s do some harder checks.
def test_targeting(self):
controller = ShotController()
player_x = 100
fleet_x = 0
# 0 64 128 192 256
assert controller.target_column(player_x, fleet_x) == 2
fleet_x = 192 # past him, can't hit
assert controller.target_column(player_x, fleet_x) < 0
That passes. Will we be satisfied with checking the range of the output? I’m not sure. Let’s do the other end.
I have this test and it is, of course, green:
def test_targeting(self):
controller = ShotController()
player_x = 100
fleet_x = 0
# 0 64 128 192 256
assert controller.target_column(player_x, fleet_x) == 2
fleet_x = 192 # past him, can't hit
assert controller.target_column(player_x, fleet_x) < 0 # -1 actually
fleet_x = 64 # hard left
player_x = 950 # way off to right
assert controller.target_column(player_x, fleet_x) > 10 # 14 actually
It’s really just expressing what the numbers do. But it tells me what I’d really like this function to do, which is to return only values for valid columns, i.e. between zero and ten. We’ll deal with the column being empty elsewhere.
So let me recast the test:
def test_targeting(self):
controller = ShotController()
player_x = 100
fleet_x = 0
# 0 64 128 192 256
assert controller.target_column(player_x, fleet_x) == 2
fleet_x = 192 # past him, can't hit
assert controller.target_column(player_x, fleet_x) == 0 # -1 actually
fleet_x = 64 # hard left
player_x = 950 # way off to right
assert controller.target_column(player_x, fleet_x) == 10 # 14 actually
This fails now, getting -1 and 14. So we have to change the code. It’s that max min thing, which I find hard to write. Here’s what I do to get it right with “minimal” thinking and “maximal” correctness. I can get them right one at a time:
def target_column(self, player_x, fleet_x):
steps = round((player_x - fleet_x) / 64)
steps = min(steps, 10)
steps = max(0, steps)
return steps
Test is green. PyCharm, inline once:
def target_column(self, player_x, fleet_x):
steps = round((player_x - fleet_x) / 64)
steps = max(0, min(steps, 10))
return steps
Inline twice:
steps = round((player_x - fleet_x) / 64)
return max(0, min(steps, 10))
Guaranteed correct, no thinking required. We could inline again but I think that would be too hard to read. We’ll leave it this way.
Now about using it. Let’s review what we have so far in ShotController.
def fire_specific_shot(self, shot_index, fleets):
shot = self.shots[shot_index]
if shot.position == self.available:
self.select_shot_position(shot, shot_index)
fleets.append(shot)
self.shot_index = (self.shot_index + 1) % 3
def select_shot_position(self, shot, shot_index):
if shot_index == 2:
shot.position = Vector2(1000, 64)
else:
col = self.next_column_for(shot_index)
invader = self.invader_fleet.invader_group.bottom_of_column(col)
if invader:
shot.position = invader.position
else:
shot.position = Vector2(10, 10)
OK, this isn’t really great. I’ve patched two constant positions in there. The (1000, 64) makes the targeted shot drop near the bottom, because I didn’t want to see it while watching the others. That’s clearly what we want to change.
But note that if the invader group has no invaders, the inner else:
, we start the shot at (10, 10). What we should really do is not fire at all, or, I suppose, select another column. But if we just let it go again, we’ll select another column automatically.
Is select_shot_position
tested directly? It is not. So we can move responsibilities around between fire_specific_shot
and select_shot_position
as we wish. For that matter, fire_specific_shot
isn’t called from tests either.
Let’s refactor thus: Change select_shot_position
to return the position or None if there is none. Use that information in fire_specific_shot
. I get this:
def fire_specific_shot(self, shot_index, fleets):
shot = self.shots[shot_index]
if shot.position == self.available:
pos = self.select_shot_position(shot, shot_index)
if pos:
shot.position = pos
fleets.append(shot)
self.shot_index = (self.shot_index + 1) % 3
def select_shot_position(self, shot, shot_index):
if shot_index == 2:
return Vector2(1000, 64)
else:
col = self.next_column_for(shot_index)
invader = self.invader_fleet.invader_group.bottom_of_column(col)
if invader:
return invader.position
else:
return Vector2(10, 10)
Still green. I need a commit. Commit: new tests for targeting. Some refactoring.
Strictly speaking, one might argue that the above is not a refactoring, because I put in the check for pos
being non-None. On the other hand, the code never produces a None, so no worries.
Now I need two changes. First, I want the final line to return None. If we cannot find an alien in the required column, we just don’t fire. This may break some tests but I hope not.
def select_shot_position(self, shot, shot_index):
if shot_index == 2:
return Vector2(1000, 64)
else:
col = self.next_column_for(shot_index)
invader = self.invader_fleet.invader_group.bottom_of_column(col)
if invader:
return invader.position
else:
return None
So far so good. Now we need to collect up the player and fleet information. Let’s test-drive that.
def test_info_collection(self):
fleets = Fleets()
invader_fleet = InvaderFleet()
invader_fleet.origin = Vector2(123, 456)
controller = ShotController()
controller.begin_interactions(fleets)
controller.interact_with_invaderfleet(invader_fleet, fleets)
assert controller.fleet_x == 123
Does not pass. We are not surprised.
@property
def fleet_x(self):
return self.invader_fleet.origin.x
We are green. Why? Because we already snatch the invader fleet into the controller:
def interact_with_invaderfleet(self, fleet, fleets):
self.invader_fleet = fleet
We do not yet interact with player, so extend the test. No, rename this one, write a new one.
def test_captures_fleet_x(self):
fleets = Fleets()
invader_fleet = InvaderFleet()
invader_fleet.origin = Vector2(123, 456)
controller = ShotController()
controller.begin_interactions(fleets)
controller.interact_with_invaderfleet(invader_fleet, fleets)
assert controller.fleet_x == 123
def test_captures_player_x(self):
fleets = Fleets()
player = InvaderPlayer()
position = player.rect.center
controller = ShotController()
controller.begin_interactions(player)
controller.interact_with_invaderfleet(player, fleets)
assert controller.player_x == position.x
This is invasive. Small steps, OK?
Make it work:
class ShotController(InvadersFlyer):
def interact_with_invaderplayer(self, player, fleets):
self.player_x = player.position.x
class InvaderPlayer(InvadersFlyer):
@property
def position(self):
return Vector2(self.rect.center)
Test is not passing yet. It wasn’t correct. This is:
def test_captures_player_x(self):
fleets = Fleets()
player = InvaderPlayer()
position = player.position
controller = ShotController()
controller.begin_interactions(player)
controller.interact_with_invaderplayer(player, fleets)
assert controller.player_x == position.x
Green. Commit: Test-drive ShotController knows player and invader fleet x values.
Now I really need one more test, but I can’t resist doing this first:
def select_shot_position(self, shot, shot_index):
if shot_index == 2:
col = self.target_column(self.player_x, self.fleet_x)
else:
col = self.next_column_for(shot_index)
invader = self.invader_fleet.invader_group.bottom_of_column(col)
if invader:
return invader.position
else:
return None
Tests stay green. Unless I am mistaken, which I often am, the game will now drop roller shots directly over the player whenever possible.
And it does!
Made with ANYMP4
The above is my first n+mth attempt at an MP4 video. If you have difficulty with it please let me know. I’ll work on the sizing etc but first I want to get a better recorder. Thanks to my friends for letting me know this was an issue, so that I finally got off the dime to do something about it.
Made with OBS direct
Back from video hassles
None of the above seems to satisfy everyone, including me. Getting closer, I think.
I should do a test for that code. I am tired and so I am not going to do that. Most of it is already tested quite nicely: the only thing we’re really missing is the test for the shot index being 2. So make fun of me, that’s OK.
Summary
This was as easy as I thought it would be, despite that going in, I thought this was a very difficult problem, targeting. It comes down to little more than a divide and round operation.
In my experience this happens very often when we work in small steps. We carve away bits and pieces of the problem, doing each one as well as we can, keeping the code well factored and generally good. That will tend to generate a good design for all the bits we carve off, and because the code is well factored, the design will generally be easy to change.
When we come down to the last bit, which we were thinking would be hard, there’s a good chance that the nice, well-designed structure so far will have a place in it for the new feature. In our case, we already had an if statement that led directly to where the rolling shot had to go.
In addition, because we will have solved most of the other problems, there’s a good chance that our difficult feature will be able to make use of those solutions, while perhaps adding just one more to the pile. In our case, we already had the code to find the bottom of a column of invaders, which reduced our targeting problem to selecting the best column to use. And that was easy, because it’s whatever column has the closest x coordinate to the x coordinate of the player.
This sort of thing happens so often that, to me, it cannot be coincidence. Carving off small slices, working in small steps, tends to create opportunities for more small steps, right down to the end. And even when it doesn’t, it tends to leave us a clear place into which to insert our new capability. It might still be hard to solve its specific issues, but all the wiring is in place to plug it it.
Small steps, or, as we sometimes call it, story slicing. It’s a good thing.
We’ll look around to see what needs improvement, and to see what out next story should be, next time.
See you then!