P-222 - Rects, Maps, Masks
Python Asteroids+Invaders on GitHub
Everyone needs them. Let’s make that happen. Then, let’s explode some invaders.
I was thinking about rects, [bit]maps and masks, because I need maps to draw, rects to draw them in, rects and masks to check collisions. And this morning, I was wondering whether the game should create the masks right along with creating the maps. And this morning, I was wondering how to ensure that all the invaders game objects have a mask to collide, and I probably should have been thinking about how to ensure that they have a rect as well.
I’ve mostly started keeping track of where things are in a Rect object, like this:
class Invader:
def __init__(self, column, row, bitmaps):
self.bitmaps = bitmaps
self.masks = [pygame.mask.from_surface(bitmap) for bitmap in self.bitmaps]
self.column = column
self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
self.rect = pygame.Rect(0, 0, 64, 32)
self.image = 0
@property
def position(self):
return Vector2(self.rect.center)
@position.setter
def position(self, vector):
self.image = 1 if self.image == 0 else 0
self.rect.center = vector + self.relative_position
I get the masks for an invader as you see above, which means that each invader has his own version of the masks, which will be just like the masks of all the other invaders of his particular style. That’s a bit wasteful.
You’ll see that by the time I get to the end of this, I have completely forgotten about the duplication. I had been thinking about a little object to hold bitmap and mask … and it completely slipped my mind in all the excitement.
The objects don’t all work just like that, but similarly.
class InvaderPlayer(InvadersFlyer):
def __init__(self):
maker = BitmapMaker.instance()
self.players = maker.players # one turret, two explosions
self.player = self.players[0]
self.rect = self.player.get_rect()
self.rect.center = Vector2(u.SCREEN_SIZE/2, u.SCREEN_SIZE - 5*32 - 16)
self.step = 4
half_width = self.rect.width / 2
self.left = 64 + half_width
self.right = 960 - half_width
self.free_to_fire = True
self.fire_request_allowed = True
Player does not as yet have a mask. Let’s right now add mask and rect as properties that an InvadersFlyer must have. We’ll see what happens. I think we say it like this:
class InvadersFlyer(Flyer):
@property
@abstractmethod
def mask(self):
pass
@property
@abstractmethod
def rect(self):
pass
This breaks something like 24 tests.
E TypeError: Can't instantiate abstract class InvaderShot with abstract method rect
Perfect, just what I wanted. It appears, however, that Python will not recognize a simple member as satisfying those abstract methods. This seems to work:
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 = 00
@property
def rect(self):
return self._rect
@property
def mask(self):
return self.masks[self.map_index]
I am OK with that, I think. We’ll push on and see what we think when all the tests are happy again.
In InvaderFleet, I just return None for both. I am not sure whether I can get away with that but the tests improved. Bumper, similarly. TopBumper.
PlayerShot needs real ones. I do the same trick with making the members private and overloading the mask
and rect
methods. ShotExplosion also. Etc.
That’s all done, all the same way, either setting the mask and rect into private members and then implementing the properties, or just returning None for objects that have no visible appearance, like the bumpers.
I think that’s OK. tests are green, game works as well as ever. Commit: make mask and rect required properties in InvadersFlyer subclasses. Can be and are dynamic in animated invaders.
Another irritating update of 8 classes, but I think it makes things better. There is a lot of very tiny duplication going on here, though. I’m not sure what should be done about that.
Let’s do the invader explosion. It’s a single frame thing. How does ShotExplosion work?
class ShotExplosion(InvadersFlyer):
def __init__(self, position):
self.position = position
maker = BitmapMaker()
self.image = maker.player_shot_explosion
self._mask = pygame.mask.from_surface(self.image)
self._rect = self.image.get_rect()
self.image.fill("red",self.rect, special_flags=pygame.BLEND_MULT)
self.time = 0.125
@property
def mask(self):
return self._mask
@property
def rect(self):
return self._rect
def tick(self, delta_time, fleets):
self.time -= delta_time
if self.time < 0:
fleets.remove(self)
Let’s do InvaderExplosion similarly. I’ll create a new file, having at least somewhat learned my lesson.
I literally paste the whole ShotExplosion class in there and rename it. I’ll show the init in a moment, but all I really did was fetch the invader_explosion
bitmap instead of the shot explosion.
Now, however, the Invader, upon being destroyed, needs to create an explosion and put it into Fleets. And it does not have access to the Fleets instance:
def interact_with_playershot(self, shot, group):
if self.colliding(shot):
shot.hit_invader()
group.kill(self)
Maybe the group can handle this. Not remotely:
class InvaderGroup:
def kill(self, invader):
index = self.invaders.index(invader)
self.invaders.pop(index)
if self._next_invader > index:
self._next_invader -= 1
The group does not know the InvaderFleet, and the InvaderFleet does not know fleets except when it’s being called for an interaction:
class InvaderFleet(InvadersFlyer):
def interact_with_playershot(self, shot, fleets):
self.invader_group.interact_with_playershot(shot)
We can pass the fleets instance in on the interact. Let’s do that and see if we can hook it up.
class InvaderFleet(InvadersFlyer):
def interact_with_playershot(self, shot, fleets):
self.invader_group.interact_with_playershot(shot, fleets)
class InvaderGroup:
def interact_with_playershot(self, shot, fleets):
for invader in self.invaders.copy():
invader.interact_with_playershot(shot, self, fleets)
class Invader:
def interact_with_playershot(self, shot, group, fleets):
if self.colliding(shot):
shot.hit_invader()
group.kill(self)
fleets.append(InvaderExplosion(self.position))
That might just do the thing. Tests are broken, though. It’s the one that reminds me that everyone has to interact_with_invaderexplosion
now. This is tedious in the extreme. I implement it concretely in InvadersFlyer.
Insufficient effort. I have to change all the interact_with_playershot
to expect that fourth parameter.
This is a bit off. Let’s make it work, then make it right.
I change all those to expect another parameter, and the good news is that it works:
The bad news is that I think I did a bad thing. The general protocol of interact_with_object
takes three parameters, self
, object
, fleets
. I’ve changed InvadersFlyer to have one of those with four parameters instead of three. I think that’s not OK.
We should have a special method that we send to the group.
I’m going to go back to InvadersFlyer, undo the extra parameter, and fix it to work.
class InvadersFlyer(Flyer):
@abstractmethod
def interact_with_playershot(self, shot, fleets):
pass
One test fails. The refactoring tool left a parameter out. Replaced and we are OK. Let’s track down now from InvaderFleet.
class InvaderFleet(InvadersFlyer):
def interact_with_playershot(self, shot, fleets):
self.invader_group.interact_with_playershot(shot, fleets)
class InvaderGroup:
def interact_with_playershot(self, shot, fleets):
for invader in self.invaders.copy():
invader.interact_with_playershot(shot, fleets)
class Invader:
def interact_with_playershot(self, shot, group, fleets):
if self.colliding(shot):
shot.hit_invader()
group.kill(self)
fleets.append(InvaderExplosion(self.position))
This does not line up. Let’s rename that one method.
class InvaderGroup:
def interact_with_playershot(self, shot, fleets):
for invader in self.invaders.copy():
invader.interact_with_group_and_playershot(shot, self, fleets)
class Invader:
def interact_with_group_and_playershot(self, shot, group, fleets):
if self.colliding(shot):
shot.hit_invader()
group.kill(self)
fleets.append(InvaderExplosion(self.position))
Works and preserves the overall pattern. Commit: Invaders hit by player shots now display the invader explosion pattern for 0.125 seconds.
Let’s sum up.
Summary
We improved the overall design, I think, by requiring properties rect
and mask
on all the invaders objects. I have a mild concern over the ones returning None, we might do better to return an empty rect and an empty mask rather than None, but so far they have not caused problems.
However, if anyone ever did try to check collisions on them, bad things would happen.
I’m not up for fixing them right now: I want to do some testing on empty rectangles and such. We can do this, however:
class Collider:
def colliding(self):
if self.right_rect and self.right_rect and self.left_mask and self.right_mask:
return self.rectangles_colliding() and self.masks_colliding()
else:
return False
Now if any None
creep in, we’ll report no collision. I don’t like checking parameters like that, but it’s safe now.
Then we implemented ShotExplosion. I made a mistake by adding an extra parameter to a well-known method interact_with_playershot
but changed it back by providing a special entry point for the group and invader to use instead. No harm done, and it wasn’t really bad the other way, just potentially confusing, since all the other interact_with_
methods don’t have that parameter.
I dealt with the need to implement the [REDACTED] method in all kinds of classes by implementing it as non-abstract up in InvadersFlyer. Sorry / not sorry. There must be a better solution than the two that I see now, making it abstract and editing more and more classes to add it, and making it concrete and inheriting concrete behavior, with a small chance that I’ll forget to override the method where it’s needed. I have not made that mistake … lately.
Anyway, we have a nice explosion now. Next time, maybe, we’ll start the invader shots according to the original game’s scheme. That will be amusing, I think.
See you next time!