It’s oh-dark-thirty but I think I’ve got the answer.
Despite all the ImageMasher tests running, the images for the shot explosions are obviously incorrect on screen. Fiddling yesterday late afternoon, I finally found a test that doesn’t run.
def test_plus_square(self, make_target, make_square, make_plus): x = make_square x.position = (100, 200) plus = make_plus plus.position = (100, 200) plus.explosion_mask = x.mask target = make_target target.position = (100, 200) masher = ImageMasher(target, plus) masher.apply_shot() masher.apply_explosion() mask = masher.get_mask() hits =  self.check_bits(mask, hits)
This one puts a big 5x5 square around the plus sign, and the square isn’t where it should be. the output looks like this:
11111111 11111111 11111111 11100000 11100010 11100110 11101110 11100000
Clearly, the square is off. And I know why. This is the first test that has the explosion size larger than the shot size. The offset we’re using for the explosion is that of the shot, which works if they’re the same size and not otherwise. I knew it had to do that, and have no explanation for why I didn’t realize I was doing it wrong.
Anyway here’s the code as it stands, not working:
def apply_explosion(self): offset = self.shot_offset() print("masher offset", offset) mask = self.shot.explosion_mask rect = mask.get_rect() center = Vector2(rect.center) center = Vector2(0, 0) self.new_mask.erase(mask, offset - center)
That has been hacked mercilessly, and also committed, since it’s not in use. Let’s see about doing it right:
def apply_explosion(self): mask = self.shot.explosion_mask rect = mask.get_rect() rect.center = self.shot.position offset = Vector2(rect.topleft) - Vector2(self.target.rect.topleft) self.new_mask.erase(mask, offset)
And the test looks like this:
11111111 11111111 11000001 11010101 11000001 11010101 11000001 11111111
And that is what I expect. I need to set up the hits to make it green. I can do it by hand (yet again), or I could make the test print the actual hits that it sees, then check those by eye and plug them into the test.
The latter is less safe, because I might accept something that’s not actually right. But there are, what, 21 values there? Let’s get some help:
def check_bits(self, mask, hits): rect = mask.get_rect() ok = True print() actual_hits =  for y in range(rect.h): for x in range(rect.w): cell = (x, y) bit = mask.get_at(cell) if bit == 0: actual_hits.append(cell) print(bit, end="") if cell in hits: ok = ok and bit == 0 else: ok = ok and bit == 1 print() print(actual_hits) assert ok
Just save the cells and print at the end. Works as advertised. Makes a big long line but I can fold it.
And the test:
def test_plus_square(self, make_target, make_square, make_plus): x = make_square x.position = (100, 200) plus = make_plus plus.position = (100, 200) plus.explosion_mask = x.mask target = make_target target.position = (100, 200) masher = ImageMasher(target, plus) masher.apply_shot() masher.apply_explosion() mask = masher.get_mask() hits = [(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), (2, 3), (4, 3), (6, 3), (2, 4), (3, 4), (4, 4), (5, 4), (6, 4), (2, 5), (4, 5), (6, 5), (2, 6), (3, 6), (4, 6), (5, 6), (6, 6)] self.check_bits(mask, hits)
Green. I think I have some tests that I can remove. First let’s see what happens in the game.
I do a little renaming in ImageMasher:
class ImageMasher: def __init__(self, target, shot): self.target = target self.shot = shot self.new_mask = self.target.mask.copy() def offset(self, point1, point2): return Vector2(point1) - Vector2(point2) def apply_explosion(self): explosion_mask = self.shot.explosion_mask explosion_rect = explosion_mask.get_rect() explosion_rect.center = self.shot.position explosion_offset = self.offset(explosion_rect.topleft, self.target.rect.topleft) self.new_mask.erase(explosion_mask, explosion_offset) def apply_shot(self): offset = self.shot_offset() overlap = self.target.mask.overlap_mask(self.shot.mask, offset) self.new_mask.erase(overlap, (0, 0)) def get_mask(self): return self.new_mask def shot_offset(self): return self.offset(self.shot.rect.topleft, self.target.rect.topleft)
I really think that the code that sets up the explosion mask and rectangle should be in the init, but I have a lot of tests that do not agree. The reason is that they do not set up explosion mask.
Let’s get this committed and then look at that. Besides, it’s still too early to be awake.
We’re green and game is good. Commit: ImageMasher tested, works and used in Shield.
Here’s what I did in Shield, by the way:
def process_shot_collision(self, shot, explosion, explosion_mask): collider = Collider(self, shot) if collider.colliding(): self._tasks.remind_me(lambda: self.mash_image(shot)) # overlap_mask: Mask = collider.overlap_mask() # self.update_mask_and_visible_pixels(collider, explosion, explosion_mask, overlap_mask, shot)
I replaced the two commented lines with the one remind_me, which calls this:
def mash_image(self, shot): masher = ImageMasher(self, shot) masher.apply_shot() masher.apply_explosion() self._mask = masher.get_mask() rect = self._mask.get_rect() surf = self._mask.to_surface() self._map.blit(surf, rect)
This allows me to remove all this:
# def update_mask_and_visible_pixels(self, collider, explosion, explosion_mask, overlap_mask, shot): # self._tasks.remind_me(lambda: self.erase_shot_and_explosion_from_mask(shot, collider.offset(), overlap_mask, explosion, explosion_mask)) # self._tasks.remind_me(lambda: self.erase_visible_pixels(overlap_mask, self._mask)) # def erase_shot_and_explosion_from_mask(self, shot, collider_offset, shot_overlap_mask, explosion, explosion_mask): # self._mask.erase(shot_overlap_mask, (0, 0)) # self.erase_explosion_from_mask(collider_offset, explosion, explosion_mask, shot) # def erase_explosion_from_mask(self, collider_offset, explosion, explosion_mask, shot): # explosion_rect = explosion.get_rect() # explosion_rect.center = shot.rect.center # adjust_image_to_center = Vector2(explosion_rect.topleft) - Vector2(self._rect.topleft) # self._mask.erase(explosion_mask, adjust_image_to_center) # def erase_visible_pixels(self, shot_mask, shield_mask): # rect = shot_mask.get_rect() # surf = shield_mask.to_surface() # self._map.blit(surf, rect)
Commit: Remove unused code from Shield.
Now I’ll judiciously remove some tests from TestMasking, just redundant ones and a couple that are already ignored. I might do well to split that class into two, one for the generic masking tests, and one for the masher tests. But if I were do to that, I’d have to duplicate the fixtures, or inherit them (steady, Hill, I’m not gonna do that.)
I get it down to 350 lines, still huge. Commit: remove redundant tests.
I think ImageMasher can be better. It shouldn’t really take two calls to make it go. I would like it better if it set up all three components, target, shot, and explosion, in
__init__. It seems to me that the three components should all be the same type, but on input we get two Flyers, and then we fetch just an explosion mask. But in at least one case, that’s all the system currently has.
But I am pleased that I finally realized why it wasn’t working. It helped that I finally saw how to write a test that would fail. Before that, I mistakenly thought ImageMasher was correct, since my tests all passed, and that something else was wrong.
The tests and code lead me to suspect that the objects could be more helpful. Suspect? It’s plain as day, especially where we have to set the rectangle center and then ask for the toplefts, but it’s also the case that we’re reaching inside things to get information. Unfortunately, pygame isn’t as helpful as it might be, in that it has stored sequences where smarter objects would be better. In any case, we have the problem isolated in ImageMasher and even if we can improve it, the incremental improvement will be small compared to what we already have gained. We might do it for the exercise but we’re at a solid 85 or 90 percent of what I see as possible.
(That’s not quite the same as 90 percent of what is possible, since other eyes might see better than mine.)
But most of all, I’m pleased that with the help of 350 (!) lines of tests, I nearly understand masks and overlap, and the Shield code is definitely improved and working properly. The sum of Shield and ImageMasher is both shorter and simpler than what had gone before. This is a good thing.
It was worth getting up at 0330. Now I’m going back to bed. See you next time!