Encapsulation Continues
Python Asteroids+Invaders on GitHub
Today I plan to improve the Sprite object to handle animation. I am optimistic about this.
Happy December! Remember to pace and pamper yourself in this holiday season, if, like so many of us, you find it a bit stressful. Pamper your loved ones while you’re at it. In fact, even pamper people you don’t even know. We all deserve it.
Our little Sprite object currently assumes that it only has one Surface bitmap to display. We want to use it for animated objects like the Invaders and their Shots. The Player explosion is animated as well, and I have the feeling that it may raise an issue for us, so we’ll hold off on that one.
- Note
- I am not very concerned about the player explosion. I’m confident that our animated Sprite will be able to handle the case. It’s just that I think we may have to do a little extra work. I am so confident that I’m not going to explore it now. If I were less confident, I would explore it. If you were here, you’d probably be saying “Let’s just take a look”, and we would. But you’re not, and we won’t. If we get in trouble later, you can tell me you told me so.
Let’s do … the Invaders. They only have two frames of animation. Not that I think two is easier than four, it just seems like a smaller bite. We will, of course, TDD our changes, because we have that nice test file set up and so it’ll be easy to add tests.
class Invader:
def __init__(self, column, row, bitmaps):
self._score = [10, 10, 20, 20, 30][row]
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
def draw(self, screen):
if screen:
screen.blit(self.bitmaps[self.image], self.rect)
What we see here is that we change the animation frame whenever we change the Invader’s position. I am curious about how that happens. It’s done by the InvaderGroup:
class InvaderGroup:
def move_one_invader(self, origin):
invader = self.next_invader()
invader.position = origin
self._next_invader += 1
So. How should we cater to animation? Let’s review the Sprite as it stands:
class Sprite:
def __init__(self, surfaces):
self._surfaces = surfaces
self._masks = [pygame.mask.from_surface(surface) for surface in self._surfaces]
self._rectangle = self._surfaces[0].get_rect()
@property
def mask(self):
return self._masks[0]
@property
def rectangle(self):
return self._rectangle
@property
def position(self):
return Vector2(self.rectangle.center)
@position.setter
def position(self, value):
self.rectangle.center = value
def draw(self, screen):
screen.blit(self._surfaces[0], self.rectangle)
We already create the Sprite with a list of surfaces and we create a corresponding mask for each one. We assume that all the rectangles are the same, because we happen to know that they are, and so we just create one. When we return a mask, we’re currently returning the zeroth one, and similarly when we draw, we blit the zeroth surface.
We’ll do animation by setting an index inside the class, and providing a method, maybe next_frame
, that increments that index, wrapping it around, of course. And mask
and draw
will return the indexed mask and surface.
Add a test.
def test_animation(self):
maps = BitmapMaker.instance().squiggles
squiggles = Sprite(maps)
assert squiggles._frame_number == 0
for i in range(len(maps)):
assert squiggles.mask == squiggles._masks[i]
assert squiggles.surface == squiggles._surfaces[i]
squiggles.next_frame()
assert squiggles._frame_number == 0
We have a few things to implement before PyCharm stops whining.
class Sprite:
def __init__(self, surfaces):
self._surfaces = surfaces
self._masks = [pygame.mask.from_surface(surface) for surface in self._surfaces]
self._rectangle = self._surfaces[0].get_rect()
self._frame_number = 0
@property
def mask(self):
return self._masks[self._frame_number]
@property
def position(self):
return Vector2(self.rectangle.center)
@position.setter
def position(self, value):
self.rectangle.center = value
@property
def rectangle(self):
return self._rectangle
@property
def surface(self):
return self._surfaces[self._frame_number]
def next_frame(self):
self._frame_number = (self._frame_number + 1) % len(self._surfaces)
def draw(self, screen):
screen.blit(self.surface, self.rectangle)
Our tests are green. I’ll run the game to make sure the player shot still fires. It does. Commit: Sprite supports animation via next_frame.
We should be able to fix the Invader right up now. It’s a few changes, mostly copied or adapted from PlayerShot:
class Invader:
def __init__(self, column, row, bitmaps):
self._score = [10, 10, 20, 20, 30][row]
self._sprite = Sprite(bitmaps)
self.column = column
self.relative_position = Vector2(INVADER_SPACING * column, -INVADER_SPACING * row)
self.image = 0
@property
def mask(self):
return self._sprite.mask
@property
def rect(self):
return self._sprite.rectangle
@property
def position(self):
return self._sprite.position
@position.setter
def position(self, vector):
self._sprite.next_frame()
self.rect.center = vector + self.relative_position
def draw(self, screen):
self._sprite.draw(screen)
This works as advertised:
Commit: Invader uses Sprite.
Let’s sum up.
Summary
We couldn’t really ask for anything much smoother than that. The changes for animation went in easily and the installation of the object into Invader went perfectly as well.
I noticed a few things to keep in mind.
Invader had its properties spread out in the file instead of near the __init__
, which I have come to prefer. That caused me a bit more looking around and editing that would have been idea. Had I been less fortunate, I think PyCharm would have still caught any issues.
We’re creating some duplication with those new sprite-related properties. I am hopeful that some of them may ultimately go away, but we should keep an eye on that duplication. As things stand, one of the properties, the position setter, is different in the Invader, because of needing to increment the frame, and because its position calculation is different. There may be some idea lurking, like “get new position” that could abstract that away from the Sprite property setup.
It seems to me that we’ll encounter a few “phases” of installing the Sprite. We’ll of course install them one class at a time.
Then we’ll see some duplication—and some differences—in the various classes that use Sprites. We may be tempted to create a new flyer subclass / interface, maybe SpriteFlyer, which we might allow to hold the base implementation of our subclasses. If we do that GeePaw Hill will make fun of me. I can take it.
The present construction of a Sprite is odd, in that generally someone gets the basic Surface bitmaps from BitmapMaker and then immediately wraps them in a Sprite. That should probably be normalized somehow, probably a SurfaceMaker class.
All that will come along, or it won’t. We think about these things, but we hold on to them loosely, confident that the future will find us better prepared to deal with what actually happens.
Let me underline that. We’re not trying to figure out all the changes we’ll need. We’re making small changes, moving toward what seem like better situations. And we commit those changes. What if we make a bad decision and commit it, and then only find out later that it’s a bad decision?
Well, if things get really bad, we do have everything in Git and we could get back if we had to. But since every step is forward, and every step leaves everything working, more likely, if we do find ourselves somewhere we don’t want to be, we’ll just incrementally walk in a new direction. What we see in these sessions is that at the larger scale, we can invariably go forward to better, and that at the smaller scale, we rarely roll back, but when we do, it’s always within the same session’s work, and usually only one step away from the last good commit.
As my brother Hill puts it: Many More Much Smaller Steps. Small steps, carefully tested, work so well that I am continually surprised and delighted. Great fun.
Join me next time for squiggles!