P-274: Saucer
Python Asteroids+Invaders on GitHub
Let’s see about doing the saucer. How hard could it be? Well, not hard, but ragged and chaotic. I moved away from my A game for some unknown reason.
Jira Today
If I’m going to have my silly Jira, I’m going to report it. Here are the first four items, including a new one that I thought of this morning:
- Saucer
- Invaders Advance
- Two Players
- Sound
The invaders start high, and each successive wave (hey! We don’t have successive waves yet. Add that! And that reminds me of the other thing I meant to add):
- Saucer
- Invaders eat shields
- Invader Waves
- Invaders Advance
- Two Players
- Sound
Maybe this list isn’t quite as bad as I thought. But it’s still taking more time than making a sticky note, but not much more and it keeps my tray less messy.
Anyway our job this morning will be to have the Saucer fly across the screen, carrying a mystery score if we manage to shoot it.
The Saucer
- Flies at constant height and speed TBD
- Direction: Comes from right if player shot count is even
- Shares space/time with squiggly (do not implement).
- Flies if > 7 aliens exist
- Flies every 600 game loops (10 seconds)
- Scores: 10 05 05 10 15 10 10 05 30 10 10 10 05 15 10 05
- Score loops at 15, not 16 (replicate this bug?)
I have not yet been able to understand the ancient scrolls well enough to know the y coordinate of the saucer. The ancient recordings tell me that the saucer flies a bit below the score, and frankly it doesn’t look like it’s anything like ten seconds before it flies.
These are details that our product owner (me) can determine later. We’ll just build it the best we can and tweak the values as directed.
Tests
What might we test about this thing? Let’s brainstorm a few tests: maybe some of them will be easy.
- Class can be created.
- Direction can be set. (consider letting this be random).
- Saucer can draw.
- Saucer can move.
- Saucer dies when hit by player shot.
- Saucer emits explosion.
- Does player shot explode also? Perhaps not.
That seems like a good starting list. We’ll make a TestInvadersSaucer file to try to focus on tests.
class TestInvadersSaucer:
def test_exists(self):
InvadersSaucer()
That’ll drive out the class:
class InvadersSaucer:
pass
And we now have 271 tests, all passing, plus 3 ignored ones.
I suppose we’re going to have a InvadersSaucerMaker object too: that seems to be how we do these things. Let’s see about driving that out as well.
def test_maker_exists(self):
InvadersSaucerMaker()
I think I’ll put the maker in the same file as the saucer, at least for now.
class InvadersSaucerMaker:
pass
272 tests pass. Commit: initial InvadersSaucer and Maker classes and tests.
Reflection
I feel good about writing these two trivial tests, since they get me started. And I feel good about committing this early as well: gets me in the habit of small steps.
Let’s review the player maker just to see how these things might work. I begin by trimming out all those pass methods. Remember, we’ve changed our convention not to make them abstract and not to include them in the 45 lines:
class PlayerMaker(InvadersFlyer):
def __init__(self):
self.reserve = None
self.player_missing = True
@property
def mask(self):
return None
@property
def rect(self):
return None
def interact_with(self, other, fleets):
other.interact_with_playermaker(self, fleets)
def begin_interactions(self, _fleets):
self.reserve = None
self.player_missing = True
def interact_with_invaderplayer(self, _player, _fleets):
self.player_missing = False
def interact_with_reserveplayer(self, reserve, _fleets):
if not self.reserve:
self.reserve = reserve
elif reserve.reserve_number > self.reserve.reserve_number:
self.reserve = reserve
def end_interactions(self, fleets):
if self.player_missing:
if self.reserve:
fleets.remove(self)
capsule = TimeCapsule(2, InvaderPlayer(), self.reserve)
fleets.append(capsule)
fleets.append(TimeCapsule(2.1, PlayerMaker()))
else:
coin.slug(fleets)
Commit: remove pass methods.
Our saucer maker won’t be quite this elaborate. It might be able to just run on a timer, at least at first: No real need to check anything but the invader count. In fact … let’s have a look at TimeCapsule:
class TimeCapsule(InvadersFlyer):
def __init__(self, time, added_flyer, removed_flyer=None):
self.to_add = added_flyer
self.to_remove = removed_flyer
self.time = time
@property
def mask(self):
return None
@property
def rect(self):
return None
def interact_with(self, other, fleets):
other.interact_with_timecapsule(self, fleets)
def tick(self, delta_time, fleets):
self.time -= delta_time
if self.time <= 0:
fleets.remove(self)
fleets.append(self.to_add)
if self.to_remove:
fleets.remove(self.to_remove)
The TimeCapsule is initialized with a time delay, an object to add, and an optional object to remove.
If we were to just toss in a TimeCapsule for a saucer at the beginning coin, and if a dying saucer were to toss in another TimeCapsule, we wouldn’t need a maker at all.
Slice!
We’ve just seen a way to slice down our story to be even simpler than our initial thoughts. Let’s consider the first story to be something like this:
Every 10 seconds, a saucer crosses the screen in a random direction.
We can do this quite simply. We quickly inform the product owner (me) what the first slice will looks like. I agree with myself and let’s get to it.
Let’s test the saucer behavior. I type in this hopeful test:
def test_saucer_moves(self):
saucer = InvadersSaucer(1)
assert saucer.position == left_side_start
saucer.update(1.0/60.0)
assert saucer.position.x > left_side_start.x
That’s more than enough to drive out some behavior. I intend the 1 to be an indication of the direction to move. We may want to improve that but it’ll do for now.
I do this, which brings me lots of additional work to do:
class InvadersSaucer(InvadersFlyer):
def __init__(self, direction=1):
self.direction = direction
def update(self, delta_time, fleets):
pass
The inheritance of the interface requires some methods:
class InvadersSaucer(InvadersFlyer):
def __init__(self, direction=1):
self.direction = direction
@property
def mask(self):
pass
@property
def rect(self):
pass
def interact_with(self, other, fleets):
pass
def update(self, delta_time, fleets):
pass
The need for the mask reminds me that I need my maps. Let’s copy from the player:
class InvaderPlayer(InvadersFlyer):
def __init__(self):
maker = BitmapMaker.instance()
self.players = maker.players # one turret, two explosions
self.player = self.players[0]
self._mask = pygame.mask.from_surface(self.player)
self._rect = self.player.get_rect()
self.step = 4
half_width = self.rect.width / 2
self.left = 64 + half_width
self.right = 960 - half_width
self.rect.center = Vector2(self.left, u.SCREEN_SIZE - InvaderPlayerOffset)
self.free_to_fire = True
self.fire_request_allowed = True
We’ll do something similar.
Wake up!
OK, I think I’ve done way too much speculative work. Here’s what I’ve got:
class InvadersSaucer(InvadersFlyer):
def __init__(self, direction=1):
self.direction = direction
maker = BitmapMaker.instance()
self.saucers = maker.saucers # one turret, two explosions
self._map = self.saucers[0]
self._mask = pygame.mask.from_surface(self._map)
self._rect = self._map.get_rect()
half_width = self.rect.width / 2
self.left = 64 + half_width
self.right = 960 - half_width
self.rect.center = Vector2(self.left, u.SCREEN_SIZE - InvaderSaucerOffset)
@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, value):
self.rect.center = value
def interact_with(self, other, fleets):
pass
def update(self, delta_time, fleets):
pass
def draw(self, screen):
screen.blit(self._map, self.rect)
I haven’t put anything into InvaderSaucerOffset. I’ll do that. I change the line first:
self.rect.center = Vector2(self.left, u.InvaderSaucerY)
ANd set the value in u
to 128. Now I’ve got some broken tests.
InvadersFlyer does not implement interact_with_invaderssaucer
That test is carrying its weight: I need to implement the new method up in the interface.
invaderssaucer has pass in interact_with
That one pays its way as well:
def interact_with(self, other, fleets):
other.interact_with_invaderssaucer(self)
And in the test that got me here:
> assert saucer.position == left_side_start
E NameError: name 'left_side_start' is not defined
def test_saucer_moves(self):
saucer = InvadersSaucer(1)
left_side_start = Vector2(64, 128)
assert saucer.position == left_side_start
saucer.update(1.0/60.0, [])
assert saucer.position.x > left_side_start.x
Now that test is failing because I don’t move the saucer. How fast does a shot move? Their velocity is 4*4, 16. We’ll try that.
def update(self, delta_time, fleets):
x = self.position.x + 16
x_max = 960
if x > x_max:
fleets.remove(self)
else:
self.position = (x, self.position.y)
I am going way beyond my remit here. I have no test for removing. I can get the tests to run with this:
class InvadersSaucer(InvadersFlyer):
def __init__(self, direction=1):
self.direction = direction
maker = BitmapMaker.instance()
self.saucers = maker.saucers # one turret, two explosions
self._map = self.saucers[0]
self._mask = pygame.mask.from_surface(self._map)
self._rect = self._map.get_rect()
self.left = 64
self.rect.center = Vector2(self.left, u.InvaderSaucerY)
I should listen to myself.
I’ve gone seriously chaotic, but I want to at least see what happens if I create one of these things.
coin.py
def invaders(fleets):
fleets.clear()
left_bumper = 64
fleets.append(Bumper(left_bumper, -1))
fleets.append(Bumper(960, +1))
fleets.append(TopBumper())
fleets.append(InvaderFleet())
fleets.append(PlayerMaker())
fleets.append(ShotController())
fleets.append(InvaderScoreKeeper())
fleets.append(RoadFurniture.bottom_line())
fleets.append(TimeCapsule(10, InvadersSaucer(1)))
...
Run. The saucer appears for a moment, then:
TypeError: InvadersFlyer.interact_with_invaderssaucer() missing 1 required positional argument: 'fleets'
def interact_with(self, other, fleets):
other.interact_with_invaderssaucer(self, fleets)
The line of code was squiggled by PyCharm. I should have seen that. I need to settle down. One more run.
The saucer appears at the left and streaks across the screen. Success!
I need to settle down. Some remarks, then a break.
Reflection
Somehow I got impatient, started adding code that wasn’t called for in my tests. I copy-pasted the init, would probably have done better to learn from the player rather than just copy.
- Note Wording
- I say “would probably have done better”, not “I should have”. I might even say “I would prefer to have”. I am reluctant to say “should”: it implies some kind of imposed commandment that I have somehow broken. I know that things go better when I move more slowly, guided by tests. I wish I had done that. I will focus on that when I come back to this effort.
Why did I go off my A game? I don’t know. Even my remarks in the text above tell me that I knew I wasn’t operating at my best, but I just kept on. Elephant got away from the rider, I guess. No blame, but I will resolve to listen to myself a bit better, and try to slow down if I get ahead of myself.
Now the fact is that nothing went terribly wrong, and my tests found almost all the problems that I caused, and one run-time error found the mistake I made in setting up interact_with
, after I failed to notice the squiggles from PyCharm.
My goal is to work well, not to “get away” with working sloppily.
I “got away with it”. That’s not how I prefer to work. I have a fair amount of untested code here, and some magic numbers that have not been thought about, and probably other issues that we’ll look at next time.
It’s good enough to commit, just barely. Commit: saucer streaks across screen, once, after ten seconds.
Now I’ll publish this, take a break, and come back next time resolved to do better. We’ll start with a review of the tests and code and move from there.
See you next time!