P-242 - Toward ImageMasher
Python Asteroids+Invaders on GitHub
I believe I’m close to understanding masks well enough to build a little object. Another test or two and then I hope to try something. Chaos may or may not ensue.
- Added in Post
- Chaos does indeed ensue, mostly because I drew an example on a card and drew a different picture than the one in my test. So I kept wondering why the numbers were off. We do get to the understanding but not to the new object.
-
I do think my use of the test is rather nice, a decent way to back into understanding when we can’t just leap to the answer, which sometimes (cough often) happens. Read on …
As what we did yesterday began to seep into my brain, I have come to think that if I position my objects by their centers—which I do—and refer to the rectangles (rect
) via topleft
, It may be that everything will sort out nicely. My current test is this:
def test_use_both_rects_in_overlap(self, make_missile, make_target):
missile = make_missile
target = make_target
target.rect.center = (100, 200)
assert target.rect.topleft == (100-4, 200-4)
missile.rect.center = (103, 196)
offset = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
assert offset == Vector2(6, -1)
overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, offset)
rect = overlap_mask.get_rect()
assert rect == (0, 0, 8, 8) # always relative
assert rect.topleft == (0, 0)
assert overlap_mask.get_at((7, 0)) == 1
assert overlap_mask.get_at((7, 1)) == 1
assert overlap_mask.get_at((7, 2)) == 0
Masks are always origin (0, 0). So that means that I can erase the overlapping bits like this:
assert target.mask.get_at((7, 0)) == 1
assert target.mask.get_at((7, 1)) == 1
target.mask.erase(overlap_mask, (0, 0))
assert target.mask.get_at((7, 0)) == 0
assert target.mask.get_at((7, 1)) == 0
In the shield collision, we erase the shot overlap and an additional set of bits representing the explosion of the shot. The code above will handle the shot overlap. Let’s create a fake explosion and try to erase it.
Our explosion will be a 5x5 square, centered on the missile center, set to erase everything. Let’s extend our test by intention …
Much fiddling ensues…
I want to write the following test:
- An 8x8 target centered at (100, 200), as before.
- A 3x3 T-shaped missile, centered at (103, 196), as before.
- A 5x5 explosion, intended to occur at the missile center.
- Position explosion and erase
- Find that (6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7,2) are erased
- Added in Post
- This is incorrect. The 5x5 will erase (5, 0) through (7, 2). I was working with an incorrect sketch on paper. This will cause me some confusion most of which takes place in the cracks between lines of text here.
I believe that the offset needed will be something like the difference between the centers of the missile and the explosion.
Here’s the test after checking just the missile. I’ve written a new check_bits
that checks all the bits in the target mask, so that we know that only the two desired bits are clear:
def test_explosion_offset(self, make_missile, make_target, make_explosion):
missile = make_missile
missile.rect.center = (103, 196)
target = make_target
target.rect.center = (100, 200)
explosion = make_explosion
assert explosion.rect.center == (2, 2)
targeting_offset = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, targeting_offset)
target.mask.erase(overlap_mask, (0, 0))
hits = [(7, 0), (7, 1)]
self.check_bits(target.mask, hits)
def check_bits(self, mask, hits):
rect = mask.get_rect()
for y in range(rect.h):
for x in range(rect.w):
cell = (x, y)
bit = mask.get_at(cell)
if cell in hits:
assert bit == 0
else:
assert bit == 1
- Added in Post
- I think this was a pretty good move. Instead of checking only erased bits, the test can now check for bits that should not be erased. As you’ll see I refine the check a bit below so that it can print the result, which helps me when I get it wrong.
Now we’ll refill the mask, and do the explosion.
I am mistaken about the offset and I think I know why. Here’s the test. I modified the check_bits
to dump the mask so that I can see what’s up.
- Added in Post
- I’m nearly right, but still working from a bad diagram. Slows me down, because my test isn’t correct yet.
def test_explosion_offset(self, make_missile, make_target, make_explosion):
missile = make_missile
missile.rect.center = (103, 196)
target = make_target
target.rect.center = (100, 200)
explosion = make_explosion
assert explosion.rect.center == (2, 2)
targeting_offset = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, targeting_offset)
target.mask.erase(overlap_mask, (0, 0))
hits = [(7, 0), (7, 1)]
self.check_bits(target.mask, hits)
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
offset = (-1, -1)
target.mask.erase(explosion.mask, offset)
hits = [(6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
def check_bits(self, mask, hits):
rect = mask.get_rect()
ok = True
print()
for y in range(rect.h):
for x in range(rect.w):
cell = (x, y)
bit = mask.get_at(cell)
print(bit, end="")
if cell in hits:
ok = ok and bit == 0
else:
ok = ok and bit == 1
print()
assert ok
The result for the final check is this:
00001111
00001111
00001111
00001111
11111111
11111111
11111111
11111111
Right. I forgot to account for the fact that the original overlap mask is set up so that (0, 0) works. I need to take into account both the centers as targeted, and the difference between the sizes of the two erasing objects. First let’s hammer the offset in the test to be what we need. We need y
to be one less than it is, and x
to be, what, 6 or 7 more?
This passes:
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
offset = Vector2(-1, -1) + Vector2(7, -1)
target.mask.erase(explosion.mask, offset)
hits = [(6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
Why did I do that sum? Because I need the meaning of those two vectors. I think I know one of them, extract variable:
- Added in Post
- What is happening here is this: I can’t quite see the vector arithmetic that will give me the right values. (This is due in part to the fact that I have the test wrong, but mostly just because I can’t quite see it.)
-
But I feel sure that it will be the sum (or difference) of just a couple of vectors. So I break out a named variable, first with an assigned value (-1, -1). I plan to work out what that vector is, that is, what values it is the sum or difference of, and that will be part of my final calculation.
-
I plan then to extract the other bit the same way, and discover what sum or difference it is. And in fact that all works out.
-
Why do I think this will work? Well, it’s a combination of intuition, guessing, and hope. I’m pretty sure that the size difference between the missile and explosion rectangles will play in, because it will change the top left corner of the one relative to the other. And it seems likely that since we have to move everything over from the left to the right, that another add or subtract using (probably) toplefts will give us that number.
-
I want to underline this approach because while I really feel that with a thousand years of experience and two math degrees I should just see the answer, I do not. But I can work my way to it by extracting out things that I do know, leaving things that I don’t know yet, and work my way to the answer.
-
So I think this was a fairly crafty move on my part, to sneak up on an answer that I wish I could have just figured out in my head.
-
Here’s the extract done:
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
raw_center_difference = Vector2(-1, -1) # extracted
offset = raw_center_difference + Vector2(7, -1)
target.mask.erase(explosion.mask, offset)
hits = [(6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
What do I mean by raw_center_difference
? I mean the difference between the top lefts of the missile and explosion if they are centered in the same place.
Let’s compute that and assert it.
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
raw_center_difference = Vector2(explosion.rect.topleft) - Vector2(missile.rect.topleft)
assert raw_center_difference == (-1, -1)
offset = raw_center_difference + Vector2(7, -1)
target.mask.erase(explosion.mask, offset)
hits = [(6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
That passes and the calculation of raw_center_difference
is one of the calculations we need. Now what’s that other thing?
That looks to me to be the difference between the target topleft and the missile topleft.
I’m going to call that target_offset and extract to this:
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
raw_center_difference = Vector2(explosion.rect.topleft) - Vector2(missile.rect.topleft)
assert raw_center_difference == (-1, -1)
target_offset = Vector2(7, -1)
assert target_offset == Vector2(7, -1)
offset = raw_center_difference + target_offset
target.mask.erase(explosion.mask, offset)
hits = [(6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
Now to find the expression for the value.
A long delay ensues…
Damn me. I’ve been drawing my thinking picture wrong all this time. No wonder things are off by one. Tiny fool!
By Jove, I think I’ve got it! I change the test to require the right answer, which turns out to help a lot with understanding how things should work. This test now runs and makes sense to me:
def test_explosion_offset(self, make_missile, make_target, make_explosion):
missile = make_missile
missile.rect.center = (103, 196)
target = make_target
target.rect.center = (100, 200)
explosion = make_explosion
assert explosion.rect.center == (2, 2)
targeting_offset = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
overlap_mask: pygame.Mask = target.mask.overlap_mask(missile.mask, targeting_offset)
target.mask.erase(overlap_mask, (0, 0))
hits = [(7, 0), (7, 1)]
self.check_bits(target.mask, hits)
target.mask.fill()
self.check_bits(target.mask, [])
explosion.rect.center = missile.rect.center # hitting at same point
dist_target_tl_to_missile_tl = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
assert dist_target_tl_to_missile_tl == (6, -1)
dist_missile_tl_to_explosion_tl = Vector2(explosion.rect.topleft) - Vector2(missile.rect.topleft)
assert dist_missile_tl_to_explosion_tl == (-1, -1)
offset = dist_missile_tl_to_explosion_tl + dist_target_tl_to_missile_tl
assert offset == (5, -2)
target.mask.erase(explosion.mask, offset)
hits = [(5, 0), (5, 1), (5, 2), (6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
To calculate the area to erase with a fresh explosion bitmap, assumed to be located (topleft) at (0, 0) by erase, we set its offset to be the sum of two values, the topleft distance from the target to the missile, and the topleft distance from the missile to the explosion.
To get the numbers right, we need to ensure that the centers of the explosion and missile rectangles are the same. Thus the assignment at the top of the last bit, “# hitting at same point”.
Let’s rename those temps one more time:
explosion.rect.center = missile.rect.center # hitting at same point
topleft_dist_target_to_missile = Vector2(missile.rect.topleft) - Vector2(target.rect.topleft)
assert topleft_dist_target_to_missile == (6, -1)
topleft_dist_missile_to_explosion = Vector2(explosion.rect.topleft) - Vector2(missile.rect.topleft)
assert topleft_dist_missile_to_explosion == (-1, -1)
offset = topleft_dist_missile_to_explosion + topleft_dist_target_to_missile
assert offset == (5, -2)
target.mask.erase(explosion.mask, offset)
hits = [(5, 0), (5, 1), (5, 2), (6, 0), (6, 1), (6, 2), (7, 0), (7, 1), (7, 2)]
self.check_bits(target.mask, hits)
This would have been a lot easier and I’d be a lot less tired had I used the correct drawing. Here, on top of Jira, are the correct and incorrect pictures I was using:
I am tired enough to take a break. Been here from 0550 to 0905 at this instant. More than enough.
Summary
I do feel that this solution is simple enough that I “should” have been able to work it out on paper if not in my head, but I was taught not to should on myself, and even if I had figured it out, I still need a test to show that the solution works.
And I do like that test, with the check_bits
function. I like that it checks the whole array, printing it as it goes, and then fails if anything didn’t match expectations. That lets me see the whole picture of what happened, not just a coordinate pair that isn’t right. Simple trick but it has served me well when I’ve done it. (This is not my first rodeo. In fact it’s not even a rodeo at all.)
I am pleased with the refactoring steps in the test, extracting a couple of variables that I could then use to see what numbers had to be added or subtracted to get what we needed. There’s some danger that one will use some specific value that isn’t going to work in all cases, but I feel confident that we didn’t fall into that trap. I wasn’t doing numerology, I was identifying key values in the situation and differencing them. That makes sense because we need to move the things around by various vector amounts. That’s always sums and differences.
Often when we implement something, we pare off a slice, implement it, repeat. We can do the slices easily, where we might stumble trying to do the whole thing in one go. This is the same thing, paring off the components of the calculation. Doing it in a test lets us quickly check our work.
I am pretty confident that with those calculations identified, I can create some convenient object to make the mask and image updating isolated and more clear, and that with that in place, we may find it desirable to remove the Tasks use from Shield. I am still a little sad that Tasks doesn’t seem to bear its weight here, because I think it’s a pretty neat trick, but if the code is better without it, we should do without it. Kiss it up to heaven, as it were.
We’ll work on the ImageMasher, or whatever it is, soon. Probably next time. See you then!