P-244 - An Early Idea
Python Asteroids+Invaders on GitHub
An early idea got me up, to get it down, as it were. Too interesting for me to go back to sleep. Yes, I am strange.
It’s 0520. This idea started forming about about 0440 after I had awakened for reasons. It seems that it’s interesting enough that I decided to come here to the computer and start thinking and working on it.
I was thinking about a sort of flow of what objects we have at the time of a shot / shield collision, and how they are used to create the new shield image and mask. It goes something like this, just the flow, without too much regard to who knows what:
- A Shot hits the Shield.
- We need the Shield and its mask, the Shot and its mask, and the associated Shot Explosion and its mask.
- The Shield and Shot have center coordinates that are such that they are colliding.
-
The Collider that detected the collision knows the offset between the Shield and Shot. - Our job is to adjust the shield surface image and mask according to the shot mask and explosion mask.
- We overlap the Shield and Shot mask and use the result to erase the shared pixels.
- We use the Explosion mask to remove those pixels from the Shield mask.
- We use the Shield mask to adjust the Shield’s image surface.
I realized that currently, the Shield knows what explosion mask goes with which kind of hit, and, somewhat oddly passes the shot, the associated explosion surface and the associated mask into the code that ultimately munges the shield mask and surface.
That’s odd because the surface of the explosion implies the mask and can produce it, and in fact the Shield caches a copy of the two different explosion surfaces and masks. I think this was done to avoid creating the mask on every explosion, or maybe it’s just a historical artifact.
But the real point that came to me in the dark was this:
The Shield has no reason to know anything but its own aspects, and the Shot that hit it.
Now we know that we want to defer damage to the Shield until all the interactions are over, because other objects can and will look at the Shield and can and will be confused if the Shield changes its mask too soon.
So what should the Shield save up for later use? It should save the Shot that hit it, because it has no reason to know anything else.
Now when it comes time to update the Shield, using this thinking, we would have one or more instances of PlayerShot or InvaderShot saved up to do damage. (I think it is nearly certain that there will be only one, but in principle there could be more. Probably two is possible and three is right out.)
We have learned that to do the damage we need the masks from the shield and the shot, and we need the real coordinates of each, to get the relative coordinates. We also need the associated explosion mask, and we’ll need its rectangle so as to position the damage properly.
I think that’s about all we need. Certainly given the Shield, the Shot,with their positions, and the associated Explosion, we can do the job.
Some questions, some with answers, come to mind.
What object should know which Explosion goes with which Shot? I would think that the Shot should know that.
What object should know that object’s surface, mask, and rectangle? Asked and answered: The Invaders Flyer subclasses are already required to know mask
and rect
.
How will we get the offset between the Shield and the Shot, for use in aligning the Explosion with the Shield’s mask? I don’t exactly know but I know that what we need is the difference between the topleft values of the rectangles of the Shield and Shot.
A Design Emerges
So we’re starting to see an improved design. The Shield should just save up the Shots that hit it. The Shots should be able to produce their corresponding Explosion. And from those three things, we should be able to produce a revised Shield surface and mask.
One possibility to consider is to make the mask entirely virtual, that is, to compute it on the fly every time it is needed. Currently it is only needed in Collider, which does this:
class Collider:
def __init__(self, left, right):
self.left_rect = left.rect
self.left_mask = left.mask
self.right_rect = right.rect
self.right_mask = right.mask
That’s efficient if the masks are static in the left and right objects, less so if we ask pygame to compute them on each call. Something to think about, but we can surely change the Collider so that it only asks for the mask if it’s needed, and it’s really only the Shield whose mask changes at all. We’ll not worry about that, but try to keep it in mind.
Tactics
In essence what we are doing is refactoring, changing the design of this aspect of the program without changing its external behavior. Could we do it in a series of very small steps, committing after each one? Perhaps … but I suspect that some of those steps will not be machine refactorings, but things we have to do “by hand” … and we do not have tests for this code. Why? Well, at the time, it seemed like a display thing and checking the masks with tests seemed out of reach. Stuff happens.
But now we do have a lot of tests in the TestMasking class. They are mostly very low-level tests that I haven’t even written about, used to help me learn how masking works. But they do include those nifty check_bits
and mask_from_string
functions. I evolved those while writing masking tests, because I needed to see what the broken mask looked like when my code didn’t work, and because Bill Wake said something that induced me to write a function that can produce a mask from a string pattern. We’ll probably look at those again later.
Point is, we clearly need tests for the shield damage code, and there’s enough of a base for testing in TestMasking so that I feel that TDDing the thing is probably practical. I suspect that what we’ll do will be to TDD against objects whose effects are easy to assess, get it right, and then apply the code to the real objects and check those visually. We’ll see.
Let’s do it.
Do What?
Right. Well, let’s assume that we have a Shield and a Shot, both with positions, in hand, and that our job is to produce a new surface and mask for the shield, damaged by the Shot and a corresponding Shot Explosion.
We’ll create dummy objects for all three of those, at least to begin with. We’ll posit an object, ImageMasher, and see if we can TDD it into existence.
Begin With a Test
def test_masher_exists(self, make_missile, make_target):
shield = make_target
shot = make_missile
ImageMasher(shield, shot)
This is enough to force creation of ImageMasher. I’ll do that and then we’ll talk about what’s happening in the test: there’s stuff there that you may not recognize.
class ImageMasher:
pass
Now the test is happy about ImageMasher, but unhappy about the parameters on its creation. I accommodate the test:
class ImageMasher:
def __init__(self, target, shot):
self.target = target
self.shot = shot
The new test runs. Let’s review the test parameters, make_missile
and make_target
. Those are fixtures, and they return instances of a small class, Thing.
class Thing:
def __init__(self, rect, mask):
self.rect = rect
self.mask = mask
class TestMasking:
@pytest.fixture
def make_missile(self):
surf = Surface((3, 3))
# ***
# *
# *
surf.set_colorkey("black")
surf.fill("white", surf.get_rect())
surf.set_at((0, 1), "black")
surf.set_at((0, 2), "black")
surf.set_at((2, 1), "black")
surf.set_at((2, 2), "black")
mask = pygame.mask.from_surface(surf)
return Thing(mask.get_rect(), mask)
@pytest.fixture
def make_target(self):
surf = Surface((8, 8))
surf.set_colorkey("black")
surf.fill("white", surf.get_rect())
mask = pygame.mask.from_surface(surf)
return Thing(mask.get_rect(), mask)
The Thing is meant to emulate a Flyer, with just enough capability to be used in masking. We’ll probably be extending it as we goo forward. In a more strict language, we’d probably think we’re developing a new interface, Maskable or something. We may or may not do that here.
The target is just an 8x8 solid square, and the missile (shot) is T-shaped, as shown in the comment.
We’ll want to be giving these Things positions so that we can damage them. Let’s do another test and specify that. It is probably OK to position them by setting the center of their rect
.
I start the test:
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
ImageMasher(shield, shot)
PyCharm does not complain. Why? Because in their wisdom, the Python people decided that you can just go right ahead and fling a new member into any object you’ve got your hands on. The code above just sets a position
member right into the middle of my Thing.
I don’t really want that. Let’s assert that it does what I do want:
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
assert shot.rect.center == (100, 200)
ImageMasher(shield, shot)
That fails. Perfect. Add a property to Thing:
class Thing:
def __init__(self, rect, mask):
self.rect = rect
self.mask = mask
@property
def position(self):
return self.rect.center
@position.setter
def position(self, value):
self.rect.center = value
Test runs again. Moving right along … I’d like to ask the masher to apply the shot mask and then check it. I guess I’ll just say that for now:
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
assert shot.rect.center == (100, 200)
masher = ImageMasher(shield, shot)
masher.apply_shot()
mask = masher.get_mask()
self.check_bits(mask, [])
I’ll need a list for the check_bits
but I fully intend to look at the result first.
ImageMasher needs two new methods.
class ImageMasher:
def __init__(self, target, shot):
self.target = target
self.shot = shot
def apply_shot(self):
pass
def get_mask(self):
return self.target.mask
The test runs, since the masher has done nothing. But now let’s do something. I think we can just do the overlap thing:
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
assert shot.rect.center == (100, 200)
masher = ImageMasher(shield, shot)
masher.apply_shot()
mask = masher.get_mask()
self.check_bits(mask, [])
With this in ImageMasher:
def get_mask(self):
offset = Vector2(self.shot.rect.topleft) - Vector2(self.target.rect.topleft)
overlap = self.target.mask.overlap_mask(self.shot.mask, offset)
new_mask = self.target.mask.erase(overlap, offset)
return new_mask
The test fails. What does the result look like?
Nothing, because erase erases in place. Fix that. Also don’t erase in place.
def get_mask(self):
offset = Vector2(self.shot.rect.topleft) - Vector2(self.target.rect.topleft)
overlap = self.target.mask.overlap_mask(self.shot.mask, offset)
new_mask = self.target.mask.copy()
new_mask.erase(overlap, offset)
return new_mask
I do not like the result:
11111111
11111111
11111111
11111111
11111111
11111111
11111100
11111110
My plan was that the T would be in the middle, since I set both centers to (100, 200).
Let’s test the offset. I am tempted to print it but let’s actually do this right.
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
assert shot.rect.center == (100, 200)
masher = ImageMasher(shield, shot)
assert masher.shot_offset() == (666, 666)
masher.apply_shot()
mask = masher.get_mask()
self.check_bits(mask, [])
I don’t know what it’s going to be, though it looks a lot like it’ll be 8,8 or something like that. It should be around 3,3 I think but I’m willing to be told.
Extract method:
def get_mask(self):
offset = self.shot_offset()
overlap = self.target.mask.overlap_mask(self.shot.mask, offset)
new_mask = self.target.mask.copy()
new_mask.erase(overlap, offset)
return new_mask
def shot_offset(self):
return Vector2(self.shot.rect.topleft) - Vector2(self.target.rect.topleft)
And the test says:
tests/test_masking.py:236 (TestMasking.test_masher_vs_shot)
<Vector2(3, 3)> != (666, 666)
And the bug is that you don’t use the offset in the overlap erase
, you use (0, 0) because the overlap mask is already lined up.
def get_mask(self):
offset = self.shot_offset()
overlap = self.target.mask.overlap_mask(self.shot.mask, offset)
new_mask = self.target.mask.copy()
new_mask.erase(overlap, (0, 0))
return new_mask
But now we know the offset is 3, 3 which I kind of expected. And now the image is this:
11111111
11111111
11111111
11100011
11110111
11110111
11111111
11111111
That does look a bit off to the right and low, but remember, the center of the 8x8 is at (4, 4) and that’s where the center of the T is, so it’s good.
Now I do need the list for the check. I’ll just do it by eyeball this time but it’s tempting to improve check_bits
to dump out the correct list, or to write a new function to do it. But at least once we should do it by hand.
I see row (y) 3, bits (x) 3,4,5, row 4 and 5 bits 4. (3, 3), (4, 3), (5, 3), (4, 4), (4, 5).
def test_masher_vs_shot(self, make_missile, make_target):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
assert shot.rect.center == (100, 200)
masher = ImageMasher(shield, shot)
assert masher.shot_offset() == (3, 3)
masher.apply_shot()
mask = masher.get_mask()
hits = [(3, 3), (4, 3), (5, 3), (4, 4), (4, 5)]
self.check_bits(mask, hits)
I confess that, as I frequently do, I had x and y reversed at first. I often do that when I refer to row, column and that was in my mind. On the second attempt I put in the (x) and (y) to reset my brain.
Let’s pause to reflect.
Reflection
Things are going swimmingly, whatever that means. We even know that the offset is computed correctly, and the shot overlap erasure is working. We “just” need to get and apply the explosion part.
We also need to think a bit about the masher’s internals, how it will cache masks if it needs to. We’ll see. We’ll make the tests happy and then assess the masher.
OK, let’s just do an explosion that is 3x3 and apply it separately from the shot.
@pytest.fixture
def make_small_explosion(self):
mask = pygame.Mask((3, 3), fill=True)
rect = pygame.Rect(0, 0, 3, 3)
return Thing(rect, mask)
And a new test:
def test_masher_vs_shot_explosion(self, make_missile, make_target, make_explosion):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
expl = make_explosion
shot.explosion = expl
masher = ImageMasher(shield, shot)
masher.apply_explosion()
masher.get_mask()
Now we have to do some actual work, because ImageMasher is in no condition to do this new thing:
class ImageMasher:
def __init__(self, target, shot):
self.target = target
self.shot = shot
def apply_shot(self):
pass
def get_mask(self):
offset = self.shot_offset()
overlap = self.target.mask.overlap_mask(self.shot.mask, offset)
new_mask = self.target.mask.copy()
new_mask.erase(overlap, (0, 0))
return new_mask
def shot_offset(self):
return Vector2(self.shot.rect.topleft) - Vector2(self.target.rect.topleft)
We’re not even doing the work in the right place. Let’s put in a null method for apply_explosion
which will get us to a temporary green, then change how the apply_shot
works, then move on.
def apply_explosion(self):
pass
I think we’ll try a new mask member variable and see what happens.
class ImageMasher:
def __init__(self, target, shot):
self.target = target
self.shot = shot
self.new_mask = self.target.mask.copy()
def apply_explosion(self):
pass
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 Vector2(self.shot.rect.topleft) - Vector2(self.target.rect.topleft)
That’s green. We cache a copy of the target’s mask, and update it in apply_shot. We’ll do the same in apply_explosion in a moment, but first, let’s predict the output.
def test_masher_vs_shot_explosion(self, make_missile, make_target, make_explosion):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
expl = make_explosion
shot.explosion = expl
masher = ImageMasher(shield, shot)
masher.apply_explosion()
mask = masher.get_mask()
hits = [(3, 3), (4, 3), (5, 3), (3, 4), (4, 4), (5, 4), (3, 5), (4, 5), (5, 5),]
self.check_bits(mask, hits)
The square explosion should make a square hole. Make it so.
def apply_explosion(self):
offset = self.shot_offset()
mask = self.shot.explosion.mask
self.new_mask.erase(mask, offset)
I really thought that was going to work, but it doesn’t:
11111111
11111111
11111111
11100000
11100000
11100000
11100000
11100000
Looks like I used the big explosion not the small one?
def test_masher_vs_shot_explosion(self, make_missile, make_target, make_small_explosion):
shield = make_target
shield.position = (100, 200)
shot = make_missile
shot.position = (100, 200)
expl = make_small_explosion
shot.explosion = expl
masher = ImageMasher(shield, shot)
masher.apply_explosion()
mask = masher.get_mask()
hits = [(3, 3), (4, 3), (5, 3), (3, 4), (4, 4), (5, 4), (3, 5), (4, 5), (5, 5),]
self.check_bits(mask, hits)
That’s green. Also looks right. Perfect!
Well, except for how we jammed the explosion in there. I think what we want in our actual Shot classes is either a property or a method to return the proper explosion.
Let’s TDD that, since we’re so good at TDD this morning.
Ah but we have a problem … I start the test:
def test_shots_know_explosions(self):
player_shot = PlayerShot()
assert player_shot.explosion isinstance()
There does exist a class ShotExplosion, which really should be named PlayerShotExplosion. There is no class InvaderShotExplosion at all, because, so far, we have not wanted one.
Now the truth is that all we need from these “explosion” things is a mask and a rectangle. The imaginary interface Mashable or Maskable or whatever. And in fact, the ImageMasher only needs the explosion mask from the shot, not even a rectangle.
Let’s ask for explosion_mask
in the test, and give the shots that property.
def apply_explosion(self):
offset = self.shot_offset()
mask = self.shot.explosion_mask
self.new_mask.erase(mask, offset)
Now I can test those methods at least somewhat:
def test_shots_explosion_masks(self):
player_shot = PlayerShot()
assert player_shot.explosion_mask isinstance(pygame.Mask)
invader_shot = InvaderShot(None, None)
assert invader_shot.explosion_mask isinstance(pygame.Mask)
And … I’d really like those to be cached:
class InvaderShot(InvadersFlyer):
def __init__(self, position, maps):
self.maps = maps
self.masks = [pygame.mask.from_surface(bitmap) for bitmap in self.maps]
self._map = maps[0]
self.map_index = 0
self._rect = self._map.get_rect()
self.rect.center = position
self.count = 0
self.moves = 0
self._available = True
explosion = BitmapMaker.instance().invader_shot_explosion
self.explosion_mask = pygame.mask.from_surface(explosion)
class PlayerShot(InvadersFlyer):
def __init__(self, position=u.CENTER):
offset = Vector2(2, -8*4)
self.velocity = Vector2(0, -4*4)
maker = BitmapMaker.instance()
self.bits = maker.player_shot
self._mask = pygame.mask.from_surface(self.bits)
self._rect = self.bits.get_rect()
self.position = position + offset
self.should_die = False
explosion = BitmapMaker.instance().player_shot_explosion
self.explosion_mask = pygame.mask.from_surface(explosion)
Tests are green. Why not commit some of this? I think we will. Commit: TDDing ImageMasher. Minor mods to provide explosion masks in player_shot and invader_shot.
I think that now I’d like to test at least one of these things live. Let’s do PlayerShot against our rectangle target.
Yikes. I swear I saw green but the last test clearly was failing. Fixed:
def test_shots_explosion_masks(self):
player_shot = PlayerShot()
assert isinstance(player_shot.explosion_mask, pygame.Mask)
invader_shot = InvaderShot((0, 0), BitmapMaker.instance().squiggles)
assert isinstance(invader_shot.explosion_mask, pygame.Mask)
Commit: eek test was failing. fixed.
I almost regret doing that silly test.
Anyway, back to green, let’s try a PlayerShot into our 8x8.
def test_player_shot(self, make_target):
shield = make_target
shield.position = (100, 200)
shot = PlayerShot()
shot.position = (100, 200)
masher = ImageMasher(shield, shot)
masher.apply_shot()
masher.apply_explosion()
mask = masher.get_mask()
self.check_bits(mask, [])
And look at the result with fond hopes, soon to be dashed:
11000000
11000000
11000000
11000000
11000000
11000000
11000000
11000000
Well that’s just wrong. I think it’s a scaling issue. The images we use are four times the raw size, so this isn’t close to what we need. But I also don’t much like what it erased.
Let me use my check_bits to visualize the explosion mask.
11110000000000001111000000001111
11110000000000001111000000001111
11110000000000001111000000001111
11110000000000001111000000001111
00000000111100000000000011110000
00000000111100000000000011110000
00000000111100000000000011110000
00000000111100000000000011110000
00001111111111111111111111110000
00001111111111111111111111110000
00001111111111111111111111110000
00001111111111111111111111110000
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
11111111111111111111111111111111
00001111111111111111111111110000
00001111111111111111111111110000
00001111111111111111111111110000
00001111111111111111111111110000
00000000111100000000111100000000
00000000111100000000111100000000
00000000111100000000111100000000
00000000111100000000111100000000
11110000000011110000000000001111
11110000000011110000000000001111
11110000000011110000000000001111
11110000000011110000000000001111
Neat, huh? Too erratic to use in a test, though. I think I’m going to proceed on the assumption that this thing works. But we’re 600 lines into the article. Dare we continue?
I’ll roll back to green. Is there an easy spike for this?
We could remove the existing updating from shield and create a new function to defer, that just passes in the shield and the shot, which is all our new masher needs. Then the function, when called, would create a masher, run it, and update the Shield. Let’s try that.
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)
And then …
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 almost works, but not quite. The shot explosions are not in the right position, they are offset to the right:
You can see in the rightmost shield that the explosion isn’t centered on the player shot, it’s off to the right a tad.
I am tired. This problem is surely something simple, though I am not sure just what it is. We don’t have a test for doing both operations in sequence. I quickly do one and it works. Issue will be elsewhere.
But for now, I am tired. We have an ImageMasher that seems certain to be able to do the job, and the code in Shield is clearly going to be much simpler. I’m calling this a good step and taking a break. It’s Sunday, after all, and there is breakfast to be eaten and Sunday Morning to be watched.
See you next time!