P-249 - Masker Aid
Python Asteroids+Invaders on GitHub
In which, a small object helps improve our code.
One might think that the ImageMasher is about as tight as it can get, as clear as it can get. But one might recall that I’ve been saying that the code seems to be asking for another object to help out. Here’s one explanation for why that might be the case.
We’ve noted already that the ImageMasher factory method accepts two Flyers, a target and a shot. It creates the ImageMasher instance, which determines damage to the target mask, by erasing bits from the shot mask and its associated explosion mask.
class ImageMasher:
def determine_damage(self):
self.erase_shot()
self.erase_explosion()
def erase_shot(self):
self.erase_mask(self.shot_mask)
def erase_explosion(self):
self.erase_mask(self.explosion_mask)
Internally, the Masher concerns itself with manipulating various masks, rectangles and offsets as it does its work:
def erase_mask(self, shot_mask):
shot_offset = self.mask_offset_from_target(shot_mask)
self.new_mask.erase(shot_mask, shot_offset)
def mask_offset_from_target(self, mask):
explosion_rectangle = self.mask_rectangle_in_shot_position(mask)
return self.damage_offset_from_target(explosion_rectangle)
def mask_rectangle_in_shot_position(self, mask):
rectangle_moved_to_shot_position = mask.get_rect()
rectangle_moved_to_shot_position.center = self.shot_center_position
return rectangle_moved_to_shot_position
def damage_offset_from_target(self, damage_rectangle):
return self.offset(damage_rectangle.topleft, self.target_topleft_position)
@staticmethod
def offset(point1, point2):
return Vector2(point1) - Vector2(point2)
After all that messing about is done, ImageMasher is ready to return the updated mask and to blit that mask to the target surface.
- Aside
- Someone has to update the surface. Having the ImageMasher do so seems a bit off, but for now I don’t see anything that I’d prefer. But I digress.
This morning I plan to create a new helper object, a Masker. I think it will be created with a mask and a position, and that one Masker will be able to mask, i.e. erase, another. Whether it will do that in place or create a new masker, I don’t know. There are probably good arguments either way.
I am not certain about any of the above: the code has not yet had a chance to express its opinion of this idea, but I am pretty confident that we’ll wind up with something helpful.
I trust my tests (and you can be sure I’ll run the game from time to time anyway). Because I trust the existing tests, and because they won’t run if the Masker doesn’t work, we’ll create it and add it in without new tests. We might decide that tests are needed after all, but I feel confident that we can just build this thing in and then make the tests run again.
- No, by golly.
- If I proceed that way, several tests will be broken for a moderately long time as I build up the small Masker and get it correctly integrated. It’s better to go in two steps. We’ll TDD it. Not because it’s our rule, or because we always do, but because in this case, fewer tests will break. In fact, with a bit of luck, no Masher tests will break at all.
OK, TDD:
def test_masker_exists(self, make_plus):
plus = make_plus
masker = Masker(plus.mask, (100, 200))
Because the Masker is just a helper for ImageMasher, I’ll put this little class in the same file.
class Masker:
def __init__(self, mask, position):
pass
As soon as I import the new class, the test runs green.
The only operation of the masker is to erase pixels, so let’s create a simple erasure test and then, later, make it a bit harder.
I think this will do for starters:
def test_masker_centered(self):
five_data = """
11111
11111
11111
11111
"""
target_mask = self.mask_from_string(five_data)
three_data = """
111
111
111
"""
bullet_mask = self.mask_from_string(three_data)
target = Masker(five_data, (100, 200))
bullet = Masker(three_data, (100, 200))
target.erase(bullet)
erased = target.get_mask()
hits =
self.check_bits(erased, hits)
- Note
- I meant that top
five_data
to be 5x5. It isn’t. I discover that later. No harm done.
I’ll probably move those masks up as fixtures, although I only plan to use them one more time.
The new methods erase
and get_mask
need implementing. Let’s see how far I can get. It seems rather far but I don’t have a smaller idea. Also, I forgot to commit my first running test and empty class. NNo biscuit for me.
Moving forward:
class Masker:
def __init__(self, mask, position):
self.mask = mask
self.rect = mask.get_rect()
self.rect.center = position
Why did I do that? Well, because I have in mind what this object will do, I’m not discovering it as I sometimes do. It’s setting the center of the rect
so that it can use topleft
to get an offset. We’ll see how we use that in a moment.
We now need the erase
and get_mask
methods. get_mask
seems easy:
def get_mask(self):
return self.mask
Now if I implement erase as pass the test should fail nicely.
def erase(self, masker):
pass
Sure enough, the test fails, printing … something surprising me …
> self.rect = mask.get_rect()
E AttributeError: 'str' object has no attribute 'get_rect'
That’s because I interfered with the canine on the test, which should be:
bullet_mask = self.mask_from_string(three_data)
target = Masker(target_mask, (100, 200))
bullet = Masker(bullet_mask, (100, 200))
target.erase(bullet)
erased = target.get_mask()
hits = []
self.check_bits(erased, hits)
The test does not fail, however, because I’m not asking for any bits to be cleared. Let’s make it fail.
hits = [(0, 0)]
Now it prints the block I expected:
11111
11111
11111
11111
Well, almost. there are only four rows and I intended five. I am striking out more often than I get to the plate. Fix that.
11111
11111
11111
11111
11111
Perfect failure at last. Now let’s do the erase.
Note that my two objects have already set their member variable rect
centers to their position. That means that the difference between their toplefts
is the offset I need for the erase.
How should erase work? Let’s write it out longhand to begin with. We’ll try this:
def erase(self, masker):
his_mask = masker.mask
his_topleft = masker.topleft
offset = his_topleft - self.topleft
self.mask.erase(his_mask, offset)
Now I need the topleft
property.
@property
def topleft(self):
return Vector2(self.rect.topleft)
Now if this all works as I expect, there should be a hole in the middle of the test output.
11111
10001
10001
10001
11111
[(1, 1), (2, 1), (3, 1), (1, 2), (2, 2), (3, 2), (1, 3), (2, 3), (3, 3)]
Just what I wanted! Now I can paste that list into hits (Approval Test) and I’ll be green.
We are green. Commit: initial tests and Masker object.
One more test and I’ll be satisfied. I’m going to move the bullet down and two the right.
def test_masker_down_right(self):
five_data = """
11111
11111
11111
11111
11111
"""
target_mask = self.mask_from_string(five_data)
three_data = """
111
111
111
"""
bullet_mask = self.mask_from_string(three_data)
target = Masker(target_mask, (100, 200))
bullet = Masker(bullet_mask, (101, 201))
target.erase(bullet)
erased = target.get_mask()
hits = [(2, 2), (3, 2), (4, 2), (2, 3), (3, 3), (4, 3), (2, 4), (3, 4), (4, 4)]
self.check_bits(erased, hits)
I swear to you that I manually edited the hits to adjust for the movement of the bullet. And we’re green. I want to see the output so I add an assert False
to the test for a moment.
11111
11111
11000
11000
11000
As desired! I love it when a plan comes together. Green. Commit: Masker passing its two tests.
I think I’m ready to use it. Notice that I chose to update the erase
target in place. We might wish for immutability. We’ll try to think about that later. For now, I am psyched to install it.
We have this:
class ImageMasher:
@classmethod
def from_flyers(cls, target, shot):
return cls(target.mask, target.rect.topleft, shot.position, shot.mask, shot.explosion_mask)
def __init__(self, target_mask, target_topleft, shot_position, shot_mask, explosion_mask):
self.new_mask = target_mask.copy()
self.target_topleft_position = target_topleft
self.shot_center_position = shot_position
self.shot_mask = shot_mask
self.explosion_mask = explosion_mask
I think we should create the maskers in the class method and create ImageMasher with nothing but maskers. Each Masker, we recall, gets created with its mask and position. Like this:
@classmethod
def from_flyers(cls, target, shot):
target_masker = Masker(target.mask, target.position)
shot_masker = Masker(shot.mask, shot.position)
explosion_masker = Masker(shot.explosion_mask, shot.position)
return cls(target_masker, shot_masker, explosion_masker)
Of course init does not expect that, so we’ll proceed accordingly:
def __init__(self, target_masker, shot_masker, explosion_masker):
self.target_masker = target_masker
self.shot_masker = shot_masker
self.explosion_masker = explosion_masker
Now some of the methods below are not happy. Let’s look at the rest of the class so that we can appreciate how it changes.
def determine_damage(self):
self.erase_shot()
self.erase_explosion()
def erase_shot(self):
self.erase_mask(self.shot_mask)
def erase_explosion(self):
self.erase_mask(self.explosion_mask)
def erase_mask(self, shot_mask):
shot_offset = self.mask_offset_from_target(shot_mask)
self.new_mask.erase(shot_mask, shot_offset)
def mask_offset_from_target(self, mask):
explosion_rectangle = self.mask_rectangle_in_shot_position(mask)
return self.damage_offset_from_target(explosion_rectangle)
def mask_rectangle_in_shot_position(self, mask):
rectangle_moved_to_shot_position = mask.get_rect()
rectangle_moved_to_shot_position.center = self.shot_center_position
return rectangle_moved_to_shot_position
def damage_offset_from_target(self, damage_rectangle):
return self.offset(damage_rectangle.topleft, self.target_topleft_position)
@staticmethod
def offset(point1, point2):
return Vector2(point1) - Vector2(point2)
def get_new_mask(self):
return self.new_mask
def apply_damage_to_surface(self, surface):
area_to_blit = self.new_mask.get_rect()
damaged_surface = self.new_mask.to_surface()
surface.blit(damaged_surface, area_to_blit)
The first methods to be unhappy are the erase_shot
and erase_eplosion
. We have a new way to do those:
def erase_shot(self):
self.target_masker.erase(self.shot_masker)
def erase_explosion(self):
self.target_masker.erase(self.explosion_masker)
Some tests are failing, of course, since we do have ImageMasher tests that we know and love. But we’re not done here.
We are, however, almost done, because after erase_explosion
is called, the target_masker
has the updated mask. So:
def get_new_mask(self):
return self.target_masker.mask
def apply_damage_to_surface(self, surface):
new_mask = self.get_new_mask()
area_to_blit = new_mask.get_rect()
damaged_surface = new_mask.to_surface()
surface.blit(damaged_surface, area_to_blit)
The tests are green! I will check the game now. I’m glad I did because:
AttributeError: 'Shield' object has no attribute 'position'
Huh. I thought it had. Fix:
class Shield(InvadersFlyer):
@property
def position(self):
return self._rect.center
With that in place the game works perfectly. I sort of wish a test had found that missing position
method, but it was not to be. Let’s now remove all the code from ImageMasher that it does not use.
And here is the new ImageMasher, in its entirety:
class ImageMasher:
@classmethod
def from_flyers(cls, target, shot):
target_masker = Masker(target.mask, target.position)
shot_masker = Masker(shot.mask, shot.position)
explosion_masker = Masker(shot.explosion_mask, shot.position)
return cls(target_masker, shot_masker, explosion_masker)
def __init__(self, target_masker, shot_masker, explosion_masker):
self.target_masker = target_masker
self.shot_masker = shot_masker
self.explosion_masker = explosion_masker
def determine_damage(self):
self.erase_shot()
self.erase_explosion()
def erase_shot(self):
self.target_masker.erase(self.shot_masker)
def erase_explosion(self):
self.target_masker.erase(self.explosion_masker)
def get_new_mask(self):
return self.target_masker.mask
def apply_damage_to_surface(self, surface):
new_mask = self.get_new_mask()
area_to_blit = new_mask.get_rect()
damaged_surface = new_mask.to_surface()
surface.blit(damaged_surface, area_to_blit)
We have removed 19 lines from ImageMasher, just shy of half: it’s 21 lines now. Of course, we added a new class, Masker:
class Masker:
def __init__(self, mask, position):
self.mask = mask
self.rect = mask.get_rect()
self.rect.center = position
@property
def topleft(self):
return Vector2(self.rect.topleft)
def erase(self, masker):
his_mask = masker.mask
his_topleft = masker.topleft
offset = his_topleft - self.topleft
self.mask.erase(his_mask, offset)
def get_mask(self):
return self.mask
That’s 18 lines, so we haven’t reduced total code volume, but look at what we’ve done for clarity. It seems to me that what’s going on now is far more clear.
In ImageMasher the work is obvious: from the target we erase a shot and an explosion:
def determine_damage(self):
self.erase_shot()
self.erase_explosion()
We do that using three Maskers:
def erase_shot(self):
self.target_masker.erase(self.shot_masker)
def erase_explosion(self):
self.target_masker.erase(self.explosion_masker)
We return the damaged mask on request:
def get_new_mask(self):
return self.target_masker.mask
And, still a bit odd, we offer a service to apply the new mask to your surface:
def apply_damage_to_surface(self, surface):
new_mask = self.get_new_mask()
area_to_blit = new_mask.get_rect()
damaged_surface = new_mask.to_surface()
surface.blit(damaged_surface, area_to_blit)
Admit it, that’s pretty easy to figure out. But how do we do those erasures? Well, with this:
def erase(self, masker):
his_mask = masker.mask
his_topleft = masker.topleft
offset = his_topleft - self.topleft
self.mask.erase(his_mask, offset)
We get the other masker’s mask, use his topleft and ours to compute his offset relative to us, and we erase his bits in our mask.
I think I’d inline that one temp:
def erase(self, masker):
his_mask = masker.mask
offset = masker.topleft - self.topleft
self.mask.erase(his_mask, offset)
Maybe inline the mask too:
def erase(self, masker):
offset = masker.topleft - self.topleft
self.mask.erase(masker.mask, offset)
Yes. Commit: tidying.
Let’s sum up.
Summary
About eight or nine articles ago, I was uncomfortable with the code in Shield that updated its mask and surface. It seemed to have nothing to do with being a Shield and a lot to do with masks, rectangles, and points. Over a few articles, I wrote some tests to learn about masks and then we started work on ImageMasher.
I felt that that ImageMasher improved Shield, since Shield no longer had to think about all those details, but ImageMasher itself was not a paragon of clarity. We improved ImageMasher’s code over a few sessions. One key discovery or realization was that we didn’t need the overlap_mask
facility, and could just erase the shot from the shield. That made the two branches of ImageMasher, erasing shot and erasing explosion, the same, resulting in a bit of code compaction.
But there was still something odd about ImageMasher and even after a number of improvements it seemed to be digging down too deeply into its member variables—and the member variables themselves were oddly different.
Somehow, and if I could explain it better I would, it was seeming to me more and more that a small helper object would make things better. Yesterday I thought about it and this morning we implemented Masker, which neatly juggles the mask, its rectangle, its center, and its resulting top left. Those are pretty close to all being at the same level of abstraction, and the result was that Masker’s erase operation is pretty simple and makes sense.
I like the result. Shield is much simpler. ImageMasher is much simpler, half the size that it originally was. And even Masker is pretty simple, really only embodying one operation, erasure.
Small objects and “Many more much smaller steps” have improved our code base.
Worthwhile?
Well, we’ve talked about this. In this code base, as small as it is, and as unlikely to change as it is, maybe this won’t pay off in increased maintainability, speed of change, and the like.
But in my brain, my capabilities, my facility with the code, it has paid off a lot.
And if it gives you a bit of an idea … that’s absolutely worth it!
See you next time!