Python 044 - Saucer Missiles
Looks like it’s time to deal with missiles and the saucer. It fires, and the ship can shoot it. Which should I do first?
Here are the saucer story slices as they stand1 now:
- Possibly randomize saucer zig time. [NEW]
- Saucer scores 200 in initial size, more when small. (Look it up.)
- Saucer fires a missile very so often (1 second? 1/2?)
- Saucer only has two missiles on screen at a time;
- Saucer fires randomly 3/4 of the time, targeted 1/4 of the time;
- Saucer will fire across border if it’s a better shot;
- Saucer missiles do (do not) kill asteroids;
- Saucer does (does not?) destroy asteroids when it hits them;
- Saucer missiles do not add to score;
- Saucer is smaller if score > 10000 or some value;
-
Saucer start left to right, alternates right to left; -
Saucer appears every n (=7) seconds; -
Saucer is too small, should be 2x. [NEW] -
Saucer bottom appears truncated. Use larger surface. [NEW]
We could work next on ship missiles shooting down the saucer, which would have some appeal to me as a player, or we could work on having the saucer fire at the ship. I think I’d argue that to the extent that I remember anything at all, I’m probably more fresh on the saucer code, so perhaps we should do the firing. Let’s think about that a bit.
- Saucer Missiles
- The saucer is limited to two missiles on screen at a time. Presumably we would like that value to be easy to change. I honestly don’t remember whether the saucer’s missiles break asteroids or not: I think they do. If they do, obviously they shouldn’t add to our score.
-
One question we might ask is whether the saucer missiles should be a new kind of missile, i.e. a different class, or whether their difference should just be a property of some kind. It is tempting to have them be a subclass of Missile, or to give them a common superclass with Missile, but since we try these days to avoid implementation inheritance, and because we like to keep the subclassing card in our hand as long as possible, I think we’ll hold off on subclassing even if we do make a new class.
-
If we make saucer missiles their own class, it will be relatively easy to decide whether we can fire one or not: the size of their collection will tell us. If they are mixed in with the ship’s missiles, we’ll have to count them. Logically, the question is the same: are there fewer than two saucer missiles on the screen? The details of answering the question might vary but nothing else.
-
I think the simpler thing is to make them the same class and change a property to make them different.
-
Ah! I just thought: the ship only gets four missiles, so we are already counting them somewhere, and we’ll have to change that if we put them together. Let’s check that code:
class Ship:
def fire_if_possible(self, missiles):
if self.can_fire and len(missiles) < u.MISSILE_LIMIT:
missiles.append(Missile(self.missile_start(), self.missile_velocity()))
self.can_fire = False
- Saucer Missiles (cd)
- Right, if we keep the saucer missiles in with the ship’s we’ll have to futz with both. But … that doesn’t mean they have to be a different class, only that they have to have their own collection.
-
OK, I think I can sketch a plan. Let’s suppose we’ll do something like this:
Saucer “Plan”
- Have a new collection in Game, saucer_missiles.
- Have a new limit u.SAUCER_MISSILE_LIMIT.
- Put a half-second timer into Saucer.
- Try to fire if the timer has counted down.
- If there are less than two missiles in our collection, fire.
- If we fire, reset the timer.
Let’s get to it. How can we test this thing?
We can certainly test something like this:
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 1
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
I’m a little worried about how we remove them, but the ship’s missiles get removed somehow. We’ll replicate that.
We need that fire_if_possible
method.
def fire_if_possible(self, saucer_missiles):
if len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
def create_missile(self):
return Missile(u.CENTER, Vector2(70, 70))
Now what? Let’s deal with the timer getting reset. And I wonder … where will we put the firing logic during play? Too soon to worry. Let’s enhance our test:
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
saucer.fire_if_possible(saucer_missiles)
saucer.missile_timer = 0
assert len(saucer_missiles) == 1
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
Given a new constant and an init in the Saucer and this code, the test is green:
u.py
SAUCER_MISSILE_DELAY = 0.5
SAUCER_MISSILE_LIMIT = 2
class Saucer:
def __init__(self, position=None, size=2):
self.position = position if position is not None else u.CENTER
,,,
self.zig_timer = 1.5
self.missile_timer = u.SAUCER_MISSILE_DELAY
def fire_if_possible(self, saucer_missiles):
if len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
Let’s commit this: tests and code for Saucer fire_if_possible.
I’m not sure how to test putting this into the game. Let’s start and see if some testable things arise.
- Nota Bene
- I should say that I think that my friend and nemesis GeePaw Hill will generally refuse to implement any logic without a test. I’m just not that good. But I’ve shamed myself into trying a bit harder. Let’s think about how we might install it, and then see what aspects of that are testable.
- Game will add a new collection, saucer_missiles.
- Added In Post
- At the moment that I wrote the single list element above, I thought that I’d do a few and then get started. But instead, the code told me something and I just got started. When it’s time to involve the code, involve the code.
Oh! Just reviewing the game code reminds me, we send ready
to the saucer when the game restarts, so that it can reset its clock. We’d better be sure that it resets the saucer timer. I enhance the ready test:
def test_ready(self):
saucer = Saucer()
saucer.ready()
assert saucer.position.x == 0
assert saucer.velocity == u.SAUCER_VELOCITY
assert saucer.zig_timer == u.SAUCER_ZIG_TIME
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.zig_timer = 0
saucer.missile_timer = 0
saucer.ready()
assert saucer.position.x == u.SCREEN_SIZE
assert saucer.velocity == -u.SAUCER_VELOCITY
assert saucer.zig_timer == u.SAUCER_ZIG_TIME
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
That’s failing … and so is another? Fix this one:
def ready(self):
self.direction = -self.direction
self.velocity = self.direction*u.SAUCER_VELOCITY
x = 0 if self.direction > 0 else u.SCREEN_SIZE
self.position = Vector2(x, random.randrange(0, u.SCREEN_SIZE))
self.missile_timer = u.SAUCER_MISSILE_DELAY
self.zig_timer = u.SAUCER_ZIG_TIME
The new setting for missile_timer
gets that test green. What else? The error is:
> assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
E assert 0 == 0.5
E + where 0 = <saucer.Saucer object at 0x102506fd0>.missile_timer
E + and 0.5 = u.SAUCER_MISSILE_DELAY
And the test is my newest one:
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
saucer.fire_if_possible(saucer_missiles)
saucer.missile_timer = 0
assert len(saucer_missiles) == 1
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
saucer.fire_if_possible(saucer_missiles)
assert len(saucer_missiles) == 2
Wha? Well, it can only have happened in fire_if_possible
, I’d think.
def fire_if_possible(self, saucer_missiles):
if len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
That looks kind of definitely right. What is wrong? Oh. Duh. I set the timer to zero in the test, right above. It should have been set before the call to fire. Moving it up gets green. I wonder if I committed with a red test. That would be bad. Anyway commit: ready resets missile timer.
OK, where we we? Oh, right, trying to sketch what we’ll do to fire the saucer missiles. I was reviewing the game code.
- Added In Post
- I pop a few items off my stack and remember that I was planning, but I don’t go back to making a list, because I’m in the code now and it is guiding me, not my speculation about what I might do. The code likes to help. And, honestly, it’s more help than the cat.
I think we should check for firing during the move operation. That’s called from game here:
def move_everything(self,dt):
for the_saucer in self.saucers.copy():
the_saucer.move(dt, self.saucers)
for the_ship in self.ships:
the_ship.move(dt)
for asteroid in self.asteroids:
asteroid.move(dt)
for missile in self.missiles:
missile.move(dt)
We’ll want saucer.move
to be passed the saucer missiles as well as the saucers collection. (We pass saucers
because the saucer removes itself when it hits the edge.) In saucer.move, we’ll want to check the timer and fire. Since we have fire_if_possible
, we should enhance it to check the time.
Let’s make the test harder, requiring the time to have expired. All we really have to do is to add the timer check to fire_if_possible
and the existing test should fail.
def fire_if_possible(self, delta_time, saucer_missiles):
self.missile_timer =- delta_time
if self.missile_timer <= 0 and len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
I’ve added delta_time to the signature, so we’ll need to fix up the tests. I expected this to run, and it does not:
def test_can_only_fire_two(self):
saucer = Saucer()
saucer_missiles = []
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(0.1, saucer_missiles)
assert not saucer_missiles
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles)
assert len(saucer_missiles) == 1
assert saucer.missile_timer == u.SAUCER_MISSILE_DELAY
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles)
assert len(saucer_missiles) == 2
saucer.fire_if_possible(u.SAUCER_MISSILE_DELAY, saucer_missiles)
assert len(saucer_missiles) == 2
It’s failing on the assert not saucer_missiles
. We got a missile even though 0.1 < 0.5.
Ah. Nice:
self.missile_timer =- delta_time
I think I meant -=
not =-
. I would have enjoyed an error message about that but I guess it’s legal syntax. I did get a red squiggle that I didn’t notice. Fix that. Test is green. Commit: fire_if_possible checks time and available missile space.
I think I’m ready to plug this into the game. I expect to see missiles emitting from screen center when the saucer is around, but only two. And I expect them to last forever, and to hit nothing.
Oh, it might help to move and draw the saucer missiles.
With these changes, I see two missiles emit from center and fly forever.
class Game:
def __init__ ...
...
self.saucer_missiles = []
...
def draw_everything(self):
screen = self.screen
screen.fill("midnightblue")
for saucer in self.saucers:
saucer.draw(screen)
for missile in self.saucer_missiles: # <===
missile.draw(screen)
for ship in self.ships:
ship.draw(screen)
for asteroid in self.asteroids:
asteroid.draw(screen)
for missile in self.missiles:
missile.draw(screen)
self.draw_score()
self.draw_available_ships()
def move_everything(self,dt):
for the_saucer in self.saucers.copy():
the_saucer.move(dt, self.saucers, self.saucer_missiles)
for missile in self.saucer_missiles: # <====
missile.move(dt)
for the_ship in self.ships:
the_ship.move(dt)
for asteroid in self.asteroids:
asteroid.move(dt)
for missile in self.missiles:
missile.move(dt)
class Saucer:
def move(self, delta_time, saucers, saucer_missiles):
self.fire_if_possible(delta_time, saucer_missiles)
self.check_zigzag(delta_time)
self.position += delta_time*self.velocity
x = self.position.x
if x < 0 or x > u.SCREEN_SIZE:
if self in saucers:
saucers.remove(self)
def fire_if_possible(self, delta_time, saucer_missiles):
self.missile_timer -= delta_time
if self.missile_timer <= 0 and len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
def create_missile(self):
return Missile(u.CENTER, Vector2(70, 70))
Two tests are broken.
> saucer.move(0.1, [])
E TypeError: Saucer.move() missing 1 required positional argument: 'saucer_missiles'
Right. I should have checked. PyCharm is a bit more lenient about these things, I think.
I add an empty collection to the four or five test calls to saucer.move
and we’re green.
Commit: saucer emits two missiles from center. they never die, never kill anything.
Commit??
You may be wondering why I’d commit these missiles that obviously don’t work. If we were doing continuous delivery, users would see that weird train of two missiles floating forever. Well, if we were doing continuous delivery, I’d put the saucer missiles behind a feature flag, but we aren’t, so I commit it so that I can show the team and others that we’re making great progress on the missiles.
And because we’re at a reasonable limit for the size of an article. I think I’ll sum up and then continue … or take a break. Or both.
Summary
This has gone pretty well, and we have tests for the timer and the missile count. We pay a bit of a price for the use of a separate saucer missile collection, namely that we have to refer to it in several places. We should review that to see if there is something more clever to be done, but as it stands, it’s not awful. But I hate to have to make multiple changes like that, because I always forget one. Or two. Or N.
One more thing2:
Missile timeout is just this easy:
def check_missile_timeout(self):
for missile in self.missiles.copy():
missile.update(self.missiles, self.delta_time)
for missile in self.saucer_missiles.copy():
missile.update(self.saucer_missiles, self.delta_time)
Commit: saucer missiles emit from center, and time out.
That it’s that easy is a sign that things aren’t too bad around here. It would be nice if there was a more declarative way to get all these for
statements added. I’ll think about that.
I think the code is nearly clean but not super clean. For example:
def fire_if_possible(self, delta_time, saucer_missiles):
self.missile_timer -= delta_time
if self.missile_timer <= 0 and len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT:
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
Just one more thing3 …
On a given day, I could imagine something like this:
def fire_if_possible(self, delta_time, saucer_missiles):
if self.firing_is_possible(delta_time, saucer_missiles):
self.fire_a_missile(saucer_missiles)
def fire_a_missile(self, saucer_missiles):
saucer_missiles.append(self.create_missile())
self.missile_timer = u.SAUCER_MISSILE_DELAY
def firing_is_possible(self, delta_time, saucer_missiles):
return self.missile_timer_expired(delta_time) and self.a_missile_is_available(saucer_missiles)
def missile_timer_expired(self, delta_time):
self.missile_timer -= delta_time
expired = self.missile_timer <= 0
return expired
@staticmethod
def a_missile_is_available(saucer_missiles):
return len(saucer_missiles) < u.SAUCER_MISSILE_LIMIT
@staticmethod
def create_missile():
return Missile(u.CENTER, Vector2(70, 70))
Is that better? YMMV, but by my Smalltalk-primed lights, it is better. And PyCharm did most of the heavy lifting with Extract Method, Extract Variable, and Inline Expression.
Commit: refactor fire_if_possible down to the nubbins.
But let’s close this out.
Putting in saucer missiles has been pretty straightforward and the code is decent if not delicious. We’ll look at more refactoring, and of course make these missiles useful, next time. I foresee no particular difficulty. The hardest bit will come later, when we have to actually aim the missiles. That’s probably two articles from now.
See you next time!