Tiny Object
Python Asteroids+Invaders on GitHub
Will a tiny object help clarity in the InvaderFleet setup? Let’s find out.
Earlier today we left InvaderFleet looking like this:
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()
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)
We don’t really need all that info, but it can’t hurt to see it. What I don’t love is that if block in the __init__
, and as we closed this morning, I thought, and said: “Maybe a tiny object?” Let’s find out if a tiny object can help us.
This tiny object will be created with the input value start_index
, None or an integer that’s intended to be zero up to the length of u.INVADER_STARTS. It can return the desired y coordinate for the invader origin, and it can return the parameter to provide to the next InvaderFleet instance.
Let’s write tests for that.
def test_invader_origin_helper(self):
helper = OriginHelper(None)
assert helper.starting_y == 1024 - u.INVADER_FIRST_START
That requires a class and a method, which I provide:
class OriginHelper:
def __init__(self, start=None):
pass
@property
def starting_y(self):
return 1024 - u.INVADER_FIRST_START
So far so good. Test the new integer to return.
def test_invader_origin_helper(self):
helper = OriginHelper(None)
assert helper.starting_y == 1024 - u.INVADER_FIRST_START
assert helper.next_index == 0
Fake that too:
@property
def next_index(self):
return 0
Still so far so good. Let’s write a new test. Let me foreshadow what I think we’ll want, though. I think we’ll want two classes here, not just one. Maybe even three!
def test_invader_origin_helper_integer(self):
helper = OriginHelper(0)
assert helper.starting_y == 1024 - 4*u.INVADER_STARTS[0]
assert helper.next_index == 1
Seems pretty clear that that is what we wish this thing would do. Make it so, and pardon me if I go a bit beyond the test.
class OriginHelper:
def __init__(self, start=None):
self.start = start
self.index = len(u.INVADER_STARTS) + 1
@property
def starting_y(self):
if self.start is None:
self.index = len(u.INVADER_STARTS) - 1
return 1024 - u.INVADER_FIRST_START
else:
self.index = self.start % len(u.INVADER_STARTS)
return 1024 - 4*u.INVADER_STARTS[self.index]
@property
def next_index(self):
return (self.index + 1) % len(u.INVADER_STARTS)
Test is green. I do not love this because it has the same sort of iffy nature as the original init method of the InvaderFleet.
Let’s build two more tiny classes and use them.
class StartingHelper:
@property
def starting_y(self):
return 1024 - 4*u.INVADER_FIRST_START
@property
def next_index(self):
return 0
class RunningHelper:
def __init__(self, index):
self.index = index % len(u.INVADER_STARTS)
@property
def starting_y(self):
return 1024 - 4*u.INVADER_STARTS[self.index]
@property
def next_index(self):
return (self.index + 1) % len(u.INVADER_STARTS)
I didn’t test-drive these, and I apologize. But there will be extensive testing as soon as we put these into play. We’ll consider this to be a bit of a spike, in case this explodes in my face. But the two existing tests can be change like this:
def test_invader_origin_helper(self):
helper = OriginHelper.make_helper(None)
assert helper.starting_y == 1024 - 4*u.INVADER_FIRST_START
assert helper.next_index == 0
def test_invader_origin_helper_integer(self):
helper = OriginHelper.make_helper(0)
assert helper.starting_y == 1024 - 4*u.INVADER_STARTS[0]
assert helper.next_index == 1
- NOTA BENE:
- I found and fixed the missing
4*
in both the test and the code.
And:
class OriginHelper:
@classmethod
def make_helper(cls, start):
if start is None:
return StartingHelper()
else:
return RunningHelper(start)
That’s the whole OriginHelper class, since it has no need of instance methods. We could, I think, “save” a class by putting one of the implementations in here and returning the self-same class in that case. I think I will prefer this.
We could also make one of StartingHelper and RunningHelper inherit from the other and override the methods. That would be nasty.
If we were in a more strictly typed language, we would define an interface for both of these things to inherit. Pointless, in my view.
Let’s put these classes to work in the InvaderFleet:
def __init__(self, start_index=None):
self.invader_group = InvaderGroup()
helper = OriginHelper.make_helper(start_index)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, helper.starting_y)
self.next_index = helper.next_index
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def next_fleet(self):
return InvaderFleet(self.next_index)
We are green. I claim that the init here is clear and that if you want to know what the helpers do you can look.
We have this method here and it does raise a question:
@staticmethod
def convert_y_coordinate(y_on_8080):
return 0x400 - 4*y_on_8080
There is no purpose to it at this point, unless we were to use it here and have the helpers return just the 8080 values.
Let’s try that. With some renames:
class InvaderFleet(InvadersFlyer):
def __init__(self, start_index=None):
helper = OriginHelper.make_helper(start_index)
y = self.convert_y_coordinate(helper.starting_8080_y)
self.origin = Vector2(u.SCREEN_SIZE / 2 - 5 * 64, y)
self.next_index = helper.next_index
self.invader_group = InvaderGroup()
self.invader_group.position_all_invaders(self.origin)
self.direction = 1
self.step_origin()
def test_invader_origin_helper(self):
helper = OriginHelper.make_helper(None)
assert helper.starting_8080_y == u.INVADER_FIRST_START
assert helper.next_index == 0
def test_invader_origin_helper_integer(self):
helper = OriginHelper.make_helper(0)
assert helper.starting_8080_y == u.INVADER_STARTS[0]
assert helper.next_index == 1
And the helpers:
class StartingHelper:
@property
def starting_8080_y(self):
return u.INVADER_FIRST_START
@property
def next_index(self):
return 0
class RunningHelper:
def __init__(self, index):
self.index = index % len(u.INVADER_STARTS)
@property
def starting_8080_y(self):
return u.INVADER_STARTS[self.index]
@property
def next_index(self):
return (self.index + 1) % len(u.INVADER_STARTS)
class OriginHelper:
@classmethod
def make_helper(cls, start):
if start is None:
return StartingHelper()
else:
return RunningHelper(start)
We are green. I attempt to kill a rack of invaders. I fail to kill even one rack in three games. I’m sure it works, all my tests run. (What could possibly go wrong?)
I’ll hold back the commit but we’re done here.
Summary
The tiny Helper objects encapsulate the two unique cases, and the only conditional is in deciding which one to create. I prefer that to what we had. Let me know what you think!
See you next time!