Invader Advancement
Python Asteroids+Invaders on GitHub
Today I propose to make the invaders advance down the screen. I have the details. I am not entirely happy with the result, but it is solid.
From the Computer Archaeology site:
The table at 1DA3 gives the starting Y (rotated) coordinates for the alien rack with each new round.
;##-AlienStartTable
; Starting Y coordinates for aliens at beginning of rounds. The first round is initialized to $78 at 07EA.
; After that this table is used for 2nd, 3rd, 4th, 5th, 6th, 7th, 8th, and 9th. The 10th starts over at
; 1DA3 (60).
1DA3: 60
1DA4: 50
1DA5: 48
1DA6: 48
1DA7: 48
1DA8: 40
1DA9: 40
1DAA: 40
The first wave starts at Y=78 (hex). The next wave starts 16 pixels (one row of the rack) lower at Y=50. Then the rack holds at 48 for three rounds and then 40 for three rounds. The value Y=40 is just above the player's shields.
Round 7 is as hard as the game gets. By that time the player's score is above 3000, which means the aliens reload their shots at the fastest rate. The aliens never start out lower than Y=40. If you can manage the game at level 7 you can play forever.
Those hex values are pixel coordinates from the bottom of the original invaders 256 bit screen. Our screen is 1024 high, and is indexed from the top, not the bottom. Why? Someone thought it was a good idea, I guess.
So if the original starting Y was, say, y
, our starting Y should be 1024 - 4*y
. Or, if you’re a fanatic, 0x400 - 4*y
.
It’s truly odd that the very first rack of invaders starts at a Y coordinate that is never reused, but I’m sure they had their reasons.
We’ll “TDD” this, of course.1
I begin by creating some constants in u.py
:
u.py
INVADER_FIRST_START = 0x78
INVADER_STARTS = 0x60, 0x50, 0x48, 0x48, 0x48, 0x40, 0x40, 0x40
# 8080 values height = 256 = 0x100. Our screen is 4x that size and upside down.
I think first I’ll “TDD” the little calculation:
def test_start_conversion(self):
fleet = InvaderFleet()
start = fleet.convert_y_coordinate(u.INVADER_FIRST_START)
should = 1024 - 4*u.INVADER_FIRST_START
assert start == should
This fails as intended and demands:
class InvaderFleet(InvadersFlyer):
@staticmethod
def convert_y_coordinate(y_on_8080):
return 0x400 - 4*y_on_8080
Test runs. We could try some other scenarios, but we won’t. I feel fairly confident in this method. Commit: provide conversion from 8080 y coordinate to ours.
Now the actual use of the thing. I propose to provide a parameter to the creation of the InvaderFleet, which will generally be the index into the table INVADER_STARTS, a value from 0 to the length of the sequence. The default, however, will be -1, which will use the special value. I hope to find an elegant way to manage this odd first step. My own first step will be to make it work.
def test_initial_fleet_y(self):
fleet = InvaderFleet()
starting_y = fleet.origin.y
assert starting_y == 1024 - 4*u.INVADER_FIRST_START
I can fix that up a bit more easily than I would prefer. Here’s our current __init__
:
class InvaderFleet(InvadersFlyer):
def __init__(self):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, 512)
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
In the spirit of just barely making the test pass, I can do this:
class InvaderFleet(InvadersFlyer):
def __init__(self):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
That irritates me because it didn’t drive out the parameter or much of anything. I’ll do another test.
def test_initial_fleet_y(self):
fleet = InvaderFleet()
starting_y = fleet.origin.y
assert starting_y == 1024 - 4*u.INVADER_FIRST_START
def test_initial_fleet_y_with_parameter(self):
fleet = InvaderFleet(-1)
starting_y = fleet.origin.y
assert starting_y == 1024 - 4*u.INVADER_FIRST_START
The second test won’t compile, which permits me to do this:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
An old test fails:
def test_fleet_origin_is_centered(self):
fleet = InvaderFleet()
assert fleet.origin == Vector2(u.SCREEN_SIZE / 2 - 5*64, 512) + Vector2(8, 0)
invader = fleet.testing_only_invaders[5] # bottom row middle column
assert invader.position.x == 512
I’ll just remove that first assert. Green. We can commit, so let’s do: Invaders start at canonical Y coordinate.
Let’s do another test, this time for index zero.
def test_initial_fleet_y_given_0_parameter(self):
fleet = InvaderFleet(0)
starting_y = fleet.origin.y
assert starting_y == 1024 - 4*u.INVADER_STARTS[0]
Now I’m going to code what I really want, and that’s going to go beyond my remit.
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
safe_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[safe_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
This code does more than the test calls for. It’s making sure that the start_index
is in range, and it’s indexing into the table. Strict TDD done by the book, would say that that’s too much code. I say that’s the code I was ready to write. But I will write a test that demands that code:
def test_initial_fleet_y_given_8_parameter(self):
fleet = InvaderFleet(8)
starting_y = fleet.origin.y
assert starting_y == 1024 - 4*u.INVADER_STARTS[0]
Now why are we doing all this? We are doing it because when a rack is used up, we want the next rack to use the next available value in the table, starting at zero. We’ll write some more tests.
def test_fleet_after_initial_is_0_fleet(self):
fleet = InvaderFleet(-1)
next_fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[0]
next_fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[1]
Here I have a little scenario and I have more than one assert. Some folks would say you “should” only ever have one assert per test. In practice I often use more than one, to tell a little story. In fact, I think I’ll add one more to that test. Doing so helps me find the mistakes that were already there. The next test:
def test_fleet_after_initial_is_0_fleet(self):
fleet = InvaderFleet(-1)
assert fleet.origin.y == 1024 - 4*u.INVADER_FIRST_START
next_fleet = fleet.next_fleet()
assert next_fleet.origin.y == 1024 - 4*u.INVADER_STARTS[0]
next_fleet = fleet.next_fleet()
assert next_fleet.origin.y == 1024 - 4*u.INVADER_STARTS[1]
We have no method next_fleet
, so let’s get to work.
def next_fleet(self):
new_index = (self.start_index + 1) % len(u.INVADER_STARTS)
return InvaderFleet(new_index)
That also does not compile, for want of start_index
. We code:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
I am more than a little disappointed when this doesn’t work, but I think the test is wrong. It is: this one runs:
def test_fleet_after_initial_is_0_fleet(self):
fleet = InvaderFleet(-1)
assert fleet.origin.y == 1024 - 4*u.INVADER_FIRST_START
fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[0]
fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[1]
We need to check the wrap-around. We know it is in there but let’s show that we know it is in there.
def test_fleet_starting_values_including_wrap(self):
fleet = InvaderFleet(-1)
assert fleet.origin.y == 1024 - 4*u.INVADER_FIRST_START
for i in range(len(u.INVADER_STARTS)):
fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[i]
fleet = fleet.next_fleet()
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[0]
What we have not tested, however, is that the InvaderFleet actually uses the parameterized new_fleet. In fact it does not:
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)
new_fleet = InvaderFleet()
capsule = TimeCapsule(2, new_fleet)
fleets.append(capsule)
Because I am looking at it. I will fix it.
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)
Because I am on my good behavior this morning, I will make sure we actually test this. I think we have a test that inspects the TimeCapsule a bit.
def test_end_empty(self):
fleets = FakeFleets()
invader_fleet = InvaderFleet()
invader_fleet.process_result(CycleStatus.EMPTY, fleets)
assert invader_fleet in fleets.removes
added = fleets.appends[0]
assert isinstance(added, TimeCapsule)
assert isinstance(added.to_add, InvaderFleet)
assert added.time == 2
Let’s just dig a bit deeper:
def test_end_empty(self):
fleets = FakeFleets()
invader_fleet = InvaderFleet()
invader_fleet.process_result(CycleStatus.EMPTY, fleets)
assert invader_fleet in fleets.removes
added = fleets.appends[0]
assert added.time == 2
assert isinstance(added, TimeCapsule)
fleet = added.to_add
assert isinstance(fleet, InvaderFleet)
assert fleet.origin.y == 1024 - 4*u.INVADER_STARTS[0]
Green. We are confident. We are not fools. We try the game. I am still terrible at the game but I do manage to remove one rack and see the next rack start properly. Commit: Game now starts invader racks according to canon.
Eeek, a typing error in committing clobbered a test and then committed it. Quickly fixed.
What about refactoring or other tidying? Let’s see what we have wrought.
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def next_fleet(self):
new_index = (self.start_index + 1) % len(u.INVADER_STARTS)
return InvaderFleet(new_index)
@staticmethod
def convert_y_coordinate(y_on_8080):
return 0x400 - 4*y_on_8080
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)
- NOTA BENE
- You can skip from here to here if you wish. I try several different refactorings of
__init__
and accept none of them. You can save 300 lines of article by skipping.
Maybe we can improve this a bit. Let’s extract a method:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
self.init_starting_position(start_index)
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def init_starting_position(self, start_index):
if start_index == -1:
self.start_index = -1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64,
self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
This is a good start but we don’t really want to be setting up that start_index
member outside the init. So let’s edit a bit:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
self.start_index, self.origin = self.init_starting_position(start_index)
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def init_starting_position(self, start_index):
if start_index == -1:
safe_index = -1
origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
safe_index = start_index % len(u.INVADER_STARTS)
origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, self.convert_y_coordinate(u.INVADER_STARTS[safe_index]))
return safe_index, origin
I do not love this code. Roll back and do better.
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
Part of the issue is that we just plain have a weird situation with the specialized origin code, with the special setup for the initial case. Part of what makes this hard is that array[-1] is the last element, so I can’t quite do something to clever.
What about setting the start_index
to the end of the array? That would at least be in range:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = len(u.INVADER_STARTS) - 1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
This passes all those nice tests. What if we made the default None
, would we prefer that?
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=None):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index is None:
self.start_index = len(u.INVADER_STARTS) - 1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
How about this:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=None):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index is None:
start_index = len(u.INVADER_STARTS) - 1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[start_index]))
self.start_index = start_index
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
A bit naff hammering the input parameter. What if we compute and save next index here?
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=None):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index is None:
used_index = len(u.INVADER_STARTS) - 1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
used_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[used_index]))
self.next_index = (used_index + 1) % len(u.INVADER_STARTS)
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def next_fleet(self):
return InvaderFleet(self.next_index)
I still want to extract that idea. But unfortunately, the two values are tied together.
- Note
- I really do not see a way to do this that I really like. I could settle for any of these, but I am hoping to find one that I actually like.
Ah. I have an idea. Let’s do this:
INVADER_FIRST_START = 0x78
INVADER_STARTS = 0x60, 0x50, 0x48, 0x48, 0x48, 0x40, 0x40, 0x40
INVADER_STARTS_EXTENDED = 0x60, 0x50, 0x48, 0x48, 0x48, 0x40, 0x40, 0x40, 0x78
And then:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=None):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
index = start_index%len(u.INVADER_STARTS) if start_index is not None else len(u.INVADER_STARTS)
self.next_index = (index + 1) % len(u.INVADER_STARTS) if start_index is not None else 0
y_coordinate = self.convert_y_coordinate(u.INVADER_STARTS_EXTENDED[index])
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, y_coordinate)
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
No, not enough better. Roll back.
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
At this moment, I don’t see anything that I prefer over this one. But it has that trick in it, with the -1 being incremented to zero. Let me put a comment there to indicate that the code wants to be more clear.
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=-1):
self.step = Vector2(8, 0)
self.down_step = Vector2(0, 32)
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
# Wants to be more clear. This forces next index to 0 as desired after first start.
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
Skip Target
We could have a couple of class variables here, I think.
class InvaderFleet(InvadersFlyer):
step = Vector2(8, 0)
down_step = Vector2(0, 32)
def __init__(self, start_index=-1):
self.invader_group = InvaderGroup()
if start_index == -1:
self.start_index = -1
# Wants to be more clear. This forces next index to 0 as desired after first start.
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_FIRST_START))
else:
self.start_index = start_index % len(u.INVADER_STARTS)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5*64, self.convert_y_coordinate(u.INVADER_STARTS[self.start_index]))
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
I see that we are deep into long article territory. I’ll go back and mark where the interesting bit ends. Well, that might be too optimistic. I’ll go back and mark a place after which nothing much happens.
Summary
We have a functional and fairly clear implementation of the very odd startup and running situation with the invaders’ starting Y location. It kind of starts with a particularly easy setting then loops over 8 other settings. I am not in love with the code that handles this but do not see anything that I like much better. I won’t give up but enough is enough for this morning.
We did factor out a couple of class variables, which is perhaps good. Saves two slots in the object anyway. Whee, we only have like 16 gigabytes of to play around with. Two here, two there, pretty soon … nothing, really.
We have our feature. Maybe we can find better code on another day. Maybe a tiny object?
I hope to see you next time!
-
Unlike Kent Beck’s Canon TDD, my own practice is a bit more “loose”. I rarely make a written list of ideas, though sometimes I do. I don’t always test just one thing, although often I do. I refactor whenever I see something needing it that I can do safely. My friend Bill Caputo mentioned, in this context, the notion that “systems are for teachers”, taken, I think, from martial arts. Actual fighting is different from the practice of the form. Be aware that my “TDD” is not “Canon TDD”. I’ll stop putting quotes around mine soonish. ↩