Python 219 - Collision 3
Python Asteroids+Invaders on GitHub
There’s reason to believe that there’s an object in here. Let’s bring it out. I feel I picked a poor path. How could I know?
Ever since we made the bitmap collision code work for the invaders, I’ve been muttering about a new object trying to be born. I’ve offered various reasons why that seems to me to be the case, such as the fact that right now the logic is in the Invader, which is not a Flyer, and soon we’ll need similar logic in the shields and the player, which will surely be Flyer subclasses. There is another indicator that I’ve not mentioned, because it is a bit subtle in this case. It is “Feature Envy”, when an object is more concerned with other objects than with itself, or when more than one object does the same series of things.
class Invader:
def colliding(self, invaders_flyer):
return self.rectangles_overlap(invaders_flyer) and self.masks_overlap(invaders_flyer)
def masks_overlap(self, invaders_flyer):
return self.mask.overlap(invaders_flyer.mask, self.mask_offset(invaders_flyer))
def mask_offset(self, invaders_flyer):
return Vector2(invaders_flyer.rect.topleft) - Vector2(self.rect.topleft)
def rectangles_overlap(self, shot):
return self.rect.colliderect(shot.rect)
As we read colliding
, we do see references to self
, but we’re not really manipulating things that the invader really cares about all that much. In particular it has no interest in its mask other than here, and it really even ignores its rectangle except for keeping its center up to date.
We have plenty of reasons to think that this colliding
notion deserves an object of its own, one that would concern itself with two rectangles and two masks.
I was thinking of extracting the class with a sort of Strangler approach, but my friends1 last night asked me why I couldn’t just TDD the class into being, and the truth is I surely can. So we’ll try that. I think we’ll call the thing Collider, since the name is available.
I think I’ll make a new test file, and I might move the existing collision tests over to it.
Before I get a line in, I realize what a pain it is going to be to test this. We’ll see, maybe I can make it easier, but I need some surfaces, rects, and masks.
First I test my idea:
class TestCollider:
def test_idea(self):
target = Surface((3, 3))
target.set_colorkey((0, 0, 0))
target.set_at((1, 1), "white")
target_mask = pygame.mask.from_surface(target)
probe = Surface((1, 1))
probe.set_colorkey((0, 0, 0))
probe.set_at((0, 0), "white")
probe_mask = pygame.mask.from_surface(probe)
offset = (0, 0)
assert not target_mask.overlap(probe_mask, offset)
offset = (1, 1)
assert target_mask.overlap(probe_mask, offset)
The test runs. Woot! What I have, I think, is a 3x3 surface and map with a pixel in the middle (1, 1), and a 1x1 surface and map with a single pixel at (0, 0). And they overlap when I think they should.
I need a bit more stuff, I need rectangles for these guys and centers set in the rectangles. We use the surface’s own rectangles. I’m a bit concerned about setting the center of my 1x1 so I’ll test that too.
class TestCollider:
def test_idea(self):
target = Surface((3, 3))
target.set_colorkey((0, 0, 0))
target.set_at((1, 1), "white")
target_mask = pygame.mask.from_surface(target)
probe = Surface((1, 1))
probe.set_colorkey((0, 0, 0))
probe.set_at((0, 0), "white")
probe_mask = pygame.mask.from_surface(probe)
offset = (0, 0)
assert not target_mask.overlap(probe_mask, offset)
offset = (1, 1)
assert target_mask.overlap(probe_mask, offset)
target_rect = target.get_rect()
target_rect.center = (200, 200)
assert target_rect.topleft == (199, 199)
probe_rect = probe.get_rect()
probe_rect.center = (100, 100)
assert probe_rect.topleft == (100, 100)
See what I mean about testing this thing? But now I am pretty confident that I understand these surfaces, rects, and masks. I’d like to put this stuff in a fixture so that I can test my object.
Let’s make a couple of little functions to return what we need, and maybe I’ll try a pytest fixture feature. I use Extract Method twice to get this:
class TestCollider:
def make_probe(self):
probe = Surface((1, 1))
probe.set_colorkey((0, 0, 0))
probe.set_at((0, 0), "white")
probe_mask = pygame.mask.from_surface(probe)
return probe, probe_mask
def make_target(self):
target = Surface((3, 3))
target.set_colorkey((0, 0, 0))
target.set_at((1, 1), "white")
target_mask = pygame.mask.from_surface(target)
return target, target_mask
def test_idea(self):
target, target_mask = self.make_target()
probe, probe_mask = self.make_probe()
offset = (0, 0)
assert not target_mask.overlap(probe_mask, offset)
offset = (1, 1)
assert target_mask.overlap(probe_mask, offset)
target_rect = target.get_rect()
target_rect.center = (200, 200)
assert target_rect.topleft == (199, 199)
probe_rect = probe.get_rect()
probe_rect.center = (100, 100)
assert probe_rect.topleft == (100, 100)
I decide to fiddle with pytest’s fixtures a bit and now I have this:
class TestCollider:
@pytest.fixture
def make_probe(self):
probe = Surface((1, 1))
probe.set_colorkey((0, 0, 0))
probe.set_at((0, 0), "white")
probe_mask = pygame.mask.from_surface(probe)
return probe, probe_mask
@pytest.fixture
def make_target(self):
target = Surface((3, 3))
target.set_colorkey((0, 0, 0))
target.set_at((1, 1), "white")
target_mask = pygame.mask.from_surface(target)
return target, target_mask
def test_idea(self, make_target, make_probe):
target, target_mask = make_target
probe, probe_mask = make_probe
offset = (0, 0)
assert not target_mask.overlap(probe_mask, offset)
offset = (1, 1)
assert target_mask.overlap(probe_mask, offset)
def test_big_rect_center(self, make_target):
target, _target_mask = make_target
target_rect = target.get_rect()
target_rect.center = (200, 200)
assert target_rect.topleft == (199, 199)
def test_small_rect_center(self, make_probe):
probe, _probe_mask = make_probe
probe_rect = probe.get_rect()
probe_rect.center = (100, 100)
assert probe_rect.topleft == (100, 100)
I just prefixed my two helper functions with @pytest.fixture
. Once I do that, I can include the name of the fixture in the test’s parameter list and use that name as a parameter as if I had called it. In this case, it seems to me to make little difference, because I have returned two things from each helper. Still, I might be glad I learned this.
OK. With all this in place, I’m just about ready to create my object. I had almost forgotten why we were here.
As soon as I type a constructor, I am unhappy:
def test_creation(self, make_target, make_probe):
target, target_mask = make_target
probe, probe_mask = make_probe
collider = Collider(probe.get_rect(), probe_mask, target.get_rect(), target_mask)
Let’s look again at the collision code we have:
def colliding(self, invaders_flyer):
return self.rectangles_overlap(invaders_flyer) and self.masks_overlap(invaders_flyer)
def masks_overlap(self, invaders_flyer):
return self.mask.overlap(invaders_flyer.mask, self.mask_offset(invaders_flyer))
def mask_offset(self, invaders_flyer):
return Vector2(invaders_flyer.rect.topleft) - Vector2(self.rect.topleft)
def rectangles_overlap(self, shot):
return self.rect.colliderect(shot.rect)
What we have, and I’d bet what we’ll have in every case, is two objects, each of which has a rectangle and a mask. We’re interested in whether the objects are colliding and we are not interested in the rectangles and masks. So in use, I want to check two objects, not two pairs of rectangles and masks. That stuff can be dealt with inside.
I touched on this idea at some point over the past few articles, saying that we may need an interface that requires our collision candidates to support access to their rectangle and their mask.
I was angry for a moment about the tests I just wrote, but in fact they verified some important facts for me, so they’re OK. But from here I need new fixtures and I need a constructor that accepts objects (invader or shot or other flyer), and assesses collisions.
I’ll write that last test longhand, and see about extracting a fixture soon.
def test_creation(self, make_target, make_probe):
target, target_mask = make_target
target_object = {"rect":target.get_rect(),"mask":target_mask}
probe, probe_mask = make_probe
probe_object = {"rect":probe.get_rect(), "mask":probe_mask}
collider = Collider(target_object, probe_object)
I think those two dictionaries will serve as surrogate objects. We’ll find out. I am feeling my way here but I feel that I’m on the right track.
I need my collider.
Meh. Can’t use a dictionary that way. How rude. I’ll make a tiny class.
class Thing:
def __init__(self, rect, mask):
self.rect = rect
self.mask = mask
class TestCollider
def test_creation(self, make_target, make_probe):
target, target_mask = make_target
target_object = Thing(target.get_rect(), target_mask)
probe, probe_mask = make_probe
probe_object = Thing(probe.get_rect(), probe_mask)
collider = Collider(target_object, probe_object)
OK. I think that should carry me for a while. Can I extract those two bits and make two new fixtures? I’ll try it.
I extract the methods and get this:
def test_creation(self, make_target, make_probe):
target_object = self.make_target_object(make_target)
probe_object = self.make_probe_object(make_probe)
collider = Collider(target_object, probe_object)
def make_probe_object(self, make_probe):
probe, probe_mask = make_probe
probe_object = Thing(probe.get_rect(), probe_mask)
return probe_object
def make_target_object(self, make_target):
target, target_mask = make_target
target_object = Thing(target.get_rect(), target_mask)
return target_object
I am not going to try to make those into fixtures. I’m tired of messing around. I’ve got 65 lines of test here and barely have a class shell. I do have this much:
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
One more thing bugs me. In my test, how am I going to modify the rects of those objects? I can’t count on them being mutable so far down that a set in the test will touch them.
I feel like I’m going backward here. I do still like those early tests but I do not like the fixtures all that much.
I’ll try to move forward from here, but for a class with about six lines in it, this is really getting silly.
As I review this before publication, I notice that the implementation of Collider goes swiftly, smoothly, easily, slick as whatever’s slick, from here on. Could it be all that preparation that makes it seem easy?
def test_not_colliding(self, make_target, make_probe):
target_object = self.make_target_object(make_target)
probe_object = self.make_probe_object(make_probe)
collider = Collider(target_object, probe_object)
assert collider.rectangles_colliding()
assert not collider.masks_colliding()
assert not collider.colliding()
This test is consistent with the initial setup, I think. It’s failing for lack of methods, of course.
This makes the first assert pass:
class Collider:
def rectangles_colliding(self):
return self.left_rect.colliderect(self.right_rect)
Now the second:
def masks_colliding(self):
offset = (0, 0)
return self.left_mask.overlap(self.right_mask, offset)
The offset won’t withstand the next test but for this one it’s good. Now we’re failing for want of colliding
:
def colliding(self):
return self.rectangles_colliding() and self.masks_colliding()
And we are green. I want another test, with the probe moved.
def test_colliding(self, make_target, make_probe):
target_object = self.make_target_object(make_target)
probe = make_probe
probe.rect.center = (1, 1)
probe_object = self.make_probe_object(probe)
collider = Collider(target_object, probe_object)
assert collider.rectangles_colliding()
assert collider.masks_colliding()
assert collider.colliding()
That one’s failing, as expected: we’re not creating the offset yet.
def masks_colliding(self):
offset = Vector2(self.right_rect.topleft) - Vector2(self.left_rect.topleft)
return self.left_mask.overlap(self.right_mask, offset)
And it passes the test. Commit: tested and implemented Collider.
We are two hours into the morning. I am somewhat grumpy anyway and this has not made me less so. But I do think we have the object we need. Let’s plug it into the Invader and find out.
class Invader:
def colliding(self, invaders_flyer):
collider = Collider(self, invaders_flyer)
return collider.colliding()
And a collision test failed while I was putting the change in, and ran afterwards, and the game allows me to shoot invaders. Works.
But when I remove the supporting methods from Invader, tests fail that are checking those detailed methods. I’ll go clean those up, or out. I remove three that were just checking those detailed messages, and kept the one that checks invader.colliding
. Green.
Commit: Invader uses Collider.
Let’s sum up.
Summary
This was weird. I have a 20 line three method class, with 82 lines of test for it. Most of those tests, I freely grant, are there to teach me some details about pytest, and some are there to verify details about masks, but I still feel that I have wasted some time.
What does that mean? It means that given nearly perfect knowledge about what actually happened, I imagine another path that I guess would have gone faster. The imaginary path I have in mind would be something like using the existing tests to push methods over to the new Collider class one at a time from Invader, keeping the tests all running.
How much faster do I suspect it would have been, if I had done it, which I did not, and if it had gone as smoothly as I fancy, which assumes facts not in evidence2?
I don’t know. I can’t know. Still, I do feel that way. But what is wasted? Might I have gotten finished an hour sooner? Perhaps. Maybe even probably. Would I have learned about pytest fixtures? Certainly not. Would I have firmed up my understanding of masks? Probably not as much.
Is it even possible that the very quick actual implementation was smoothed by all that seemingly wasted work that went before? Could be, couldn’t it? There’s just no way to know.
We take the path we take. We know that the path wanders. Sometimes it’s a really good path, sometimes it’s so bad we double back3. We get somewhere better. We are at peace.
This is the way.
We have a nice Collider object that accepts objects that understand rect
and mask
and tells us whether they are colliding, first checking rectangles and then, if the rectangles overlap, checks the actual pixels. Very nice, makes Invader more cohesive right now and will surely be useful for the player and the shields.
We’ll work on those soon. See you then!