Encapsulation and Cohesion?
Python Asteroids+Invaders on GitHub
Design Thinking: There’s this notion of “encapsulation” that I’ve heard about. And “cohesion”?
Objects in Invaders are typically represented by bitmaps and are draw with the blit
operation, which copies a rectangular array of bits to the screen. The visible appearance of an object can change during play: the invaders each have two different bitmaps that alternate, making them appear to creep across the screen. The missiles have four different shapes, so that they seem to animate as they fall.
So these objects have a rectangle instance, called rect
. Think of all the typing I saved by not calling it rectangle
.
When the game proceeds, we want to show a “hit”, not just when the rectangles overlap, but only when the actual displayed bits of the two objects overlap. The PyGame framework supports checking for bitmap overlap using a special bitmap called a mask
. So each of our objects has or can create a mask
for use in collision checking.
And, of course, things move. A missile on one side of the screen should miss the player’s turret on the other side of the screen. So the objects have the notion of a position
, x-y coordinates saying where the object is. Because of how I like to work, the position generally represents the center of the object’s rectangle and mask.
This game, like all my work, evolved. In early days I didn’t know quite how all the bitmap stuff should work, or even how to use it, and so I just gave objects a mask, a rectangle, and (as needed) a position. As I did each object, I used these notions, but used them a bit differently, as my understanding grew. And, stuff happens, and I have never really centralized any of this logic, instead more or less leaving an object alone once it worked.
As a result, every Invaders object, even the ones that do not display or collide, has members mask
and rect
, used in similar but not identical fashion. It seems like these things belong together (cohesion) with some kind of easy to use behavior (encapsulation).
In a growing product, we often find ourselves in this situation. Code has evolved to some rough form, perhaps not entirely consistently, and we begin to feel the pain of the differences. We want to consolidate the ideas, represent them in one or more objects that “encapsulate” some key aspects of the implementation, while offering a clean “abstraction”, an API if you will, that can be used consistently and conveniently by everyone who uses this idea.
Often, doing this is difficult. Often, we cannot see just what we want. Often, the effort looks like it will require big changes and take too long, so we never get the improvement we’d like.
Starting today, I propose to work this problem in the Invaders program, with an eye to learning something about creating and building an abstraction incrementally, providing improvement over a series of separate changes.
Perhaps I can even do it. We’ll find out.
Analysis
I want to start with a bit of analysis, just looking at objects and their use of the mask
, rect
, and position
, to get a sense of what they need. One thing I am sure we’ll find: there are objects that only have these members because they are required by the interface, and never use them. This may lead us to adjust the object hierarchy … but maybe we’ll just let those objects be. We’ll focus first on the ones that actually use these members.
I happen to have PlayerShot open in PyCharm, so let’s look at it:
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)
@property
def mask(self):
return self._mask
@property
def rect(self):
return self._rect
@property
def position(self):
return Vector2(self._rect.center)
@position.setter
def position(self, vector):
self._rect.center = vector
def interact_with_invadershot(self, shot, fleets):
if self.colliding(shot):
self.explode(fleets)
def colliding(self, invaders_flyer):
collider = Collider(self, invaders_flyer)
return collider.colliding()
def draw(self, screen):
self.rect.center = self.position
screen.blit(self.bits, self.rect)
def update(self, delta_time, fleets):
self.position = self.position + self.velocity
There’s more, but those methods are enough to show most everything.
The first thing that jumps out at me is that we are keeping the position in the rect
center, but the draw
method refers directly to self.rect.center
. What would happen if we removed that line? Answer: everything still works. Makes sense, the line basically fetches rect.center
and puts it back in rect.center
. Test, commit: Remove redundant assignment of position.
Super, things are looking better already. We’d better look at Collider:
class Collider:
def __init__(self, left, right):
self.left_masker = Masker(left.mask, left.position)
self.right_masker = Masker(right.mask, right.position)
def colliding(self):
return self.left_masker.colliding(self.right_masker)
class Masker:
def __init__(self, mask, position):
self.mask = mask.copy() # don't mutate your input
self.rect = mask.get_rect()
self.rect.center = position
@property
def topleft(self):
return Vector2(self.rect.topleft)
def erase(self, masker):
self.mask.erase(masker.mask, self.offset(masker))
def colliding(self, masker):
return self.rectangles_collide(masker) and self.masks_collide(masker)
def rectangles_collide(self, masker):
return self.rect.colliderect(masker.rect)
def masks_collide(self, masker):
return self.mask.overlap(masker.mask, self.offset(masker))
def offset(self, masker):
return masker.topleft - self.topleft
def get_mask(self):
return self.mask
We see that for collision checking, we use the objects’ mask
and position
to create Maskers, and, in Masker, we are using PyGame methods like colliderect
and overlap
. The offset
used in overlap
is an inconvenience on the part of PyGame, requiring us to tell it how the two bitmaps, well, overlap. We use topleft because the bitmaps are not necessarily all the same size, so the differences between their centers, which I would find preferable, are not the same as between the topleft coordinates. In short, we do that because we have to.
I think the method get_mask
is used only in testing. Let’s verify. Yes. Shall we rename it to indicate that it’s only used in testing? As it stands, it makes me worry that some object in the game might be fetching the mask for some reason. I’ll rename it. Commit: rename method to “testing_only”.
We’re not at the bottom of the rat hole yet. There is also the ImageMasher object, which is used only by the RoadFurniture to show damage on the shields and bottom line:
I think we’ll not worry about that for now.
Based on what we’ve seen here in the PlayerShot, I am imagining a new object containing a mask, rectangle, and implementing position as the center of the rectangle. But there is also the actual object bitmap to think about, the picture. The PlayerShot stores the actual bitmap in its member bits
. Review the init:
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)
That could be better organized, couldn’t it? We’ll set that aside for now. But we definitely see that the mask and rect are derived from the bits, which are provided by the BitmapMaker. Conceivably, we should rely on the BitmapMaker to make a combined object with the bits, mask, and rectangle.
But this is the simplest case. Let’s now review a more interesting case, the InvaderShot:
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)
OK, first of all, who’s calling this thing and what are the maps?
class ShotController(InvadersFlyer):
max_firing_time = 0x30
available = Vector2(-1, -1)
def __init__(self):
self.time_since_firing = 0
self.shots = [
InvaderShot(self.available, BitmapMaker.instance().squiggles),
InvaderShot(self.available, BitmapMaker.instance().rollers),
InvaderShot(self.available, BitmapMaker.instance().plungers)]
self.columns = [
Cycler([0x00, 0x06, 0x00, 0x00, 0x00, 0x03, 0x0A, 0x00, 0x05, 0x02, 0x00, 0x00, 0x0A, 0x08, 0x01, 0x07]),
Cycler([0x0A, 0x00, 0x05, 0x02, 0x00, 0x00, 0x0A, 0x08, 0x01, 0x07, 0x01, 0x0A, 0x03, 0x06, 0x09])]
self.shot_index = 0
self.invader_fleet = None
self.player_x = 0
That available
variable is confusing. The parameter in InvaderShot is position
, so we are creating all the shots at an illegal position (-1, -1) for some reason. The reason is testing. Let’s rename that from available
to off_screen
. Green. Commit: rename available to off_screen.
Returning to thinking about these shots, we create each shot with a collection of maps, which have come from the BitmapMaker methods like squiggles
:
squiggles = ((0x44, 0xAA, 0x10), (0x88, 0x54, 0x22), (0x10, 0xAA, 0x44), (0x22, 0x54, 0x88))
These are the very bitmaps lifted from the original source code. Clearly there are four of them, corresponding to the four shapes that make up the squiggles missile.
Looking at the handling of our variables of interest, we find:
class InvaderShot(InvadersFlyer):
@property
def rect(self):
return self._rect
@property
def mask(self):
return self.masks[self.map_index]
@property
def position(self):
return Vector2(self.rect.center)
@position.setter
def position(self, vector):
self.rect.center = vector
Compare with PlayerShot:
@property
def mask(self):
return self._mask
@property
def rect(self):
return self._rect
@property
def position(self):
return Vector2(self._rect.center)
@position.setter
def position(self, vector):
self._rect.center = vector
Gratuitous order difference. What kind of … no, I forgive myself. Reorder, commit.
Other than the also gratuitous use of underbars, rect
and position
are equivalent, though one of them refers through the property and the other goes direct. We should use the property in PlayerShot. Fix that. Commit: use properties when available.
Design Thinking
I think I’ve accumulated enough understanding to start thinking about a new “abstraction”, “encapsulating” the notions of the mask, the rectangle, and the bitmaps for an object.
To support the missiles (and the invaders, at whom we have not yet looked) we’ll need to support the notion of the map_index
, which I think might be better thought of as the frame number of an animation. So our object will need a single rectangle, but a collection of masks and bitmaps, indexed by a frame number.
What will the operations of this new imagined object be? In the case of the InvaderShot, the only reference to any of this stuff has to do with draw:
def draw(self, screen):
screen.blit(self._map, self.rect)
The member _map
caches the current map:
def update_map(self):
self.map_index = (self.map_index + 1) % 4
self._map = self.maps[self.map_index]
That’s called only when the shot moves. So … suppose that our new object had a method advance_frame
, and a method draw(screen)
that drew the thing using the current frame. We could cache the map if we really cared to save the subscripting.
Our new objects could support a colliding
method readily, which they seem to need. Move would be easy.
I am now curious about who calls our property methods mask
and rect
.
mask
is called only by Collider and ImageMasher. The rect is accessed by the objects themselves, setting position and nothing else that I’ve noticed. InvaderPlayer manipulates the rect
a bit as it limits the motion of the Player, but still mostly just accessing and setting position.
Tentative Conclusion
I think, tentatively but with some confidence, that a compact little object containing a rectangle, bitmaps, and corresponding masks, and supporting the notion of position equaling the rectangle center, would be a useful abstraction, and would quite likely entirely cover two of the current three member variables. It seems likely that we could defer drawing to that object as well as colliding. I suspect, but am far from certain, that an object like that would simplify the handful of objects that would use it.
Player, PlayerShot, Invader, InvadersSaucer, InvaderShot, ReservePlayer, and RoadFurniture might all be able to use the new abstraction, plus a couple of explosion objects.
It should turn out to be an improvement, and with a little care, we can use it one object at a time. We’ll test-drive its implementation one way or another.
I am too tired from all this thinking to start working on it without a break.
We’ll work on this soon, and see what happens. My guess is that we’ll find something that’s discernibly better.