Python Asteroids on GitHub

I almost always have fun here, actually, but today let’s explode the ship. That should be a good exercise for the Fleet-based design.

In the original version of the game, when the ship was destroyed, it played a little animation of some ship fragments “exploding”. My plan today is to build something similar. I’m not going to look at the old assembly code to see how works, but I have a general recollection, and it goes like this:

When the ship is shot down or collides with an asteroid, it “explodes”. The ship itself disappears, and a handful of fragments fly off. Most of the fragments are just straight line segments, but at least one of them is a small angular bit, probably representing the nose of the ship.

I also have a tentative plan, and it goes like this:

  1. Add a new Fleet to Fleets class, an ExplosionFleet.
  2. ExplosionFleet will be populated with instances of a new class, Fragment.
  3. Each Fragment will have a position, velocity, an angle of rotation, and the ability to draw itself.
  4. On tick or move, each fragment will move according to its velocity. It might also rotate a bit: that might look good.
  5. There might be one or more subclasses of Fragment, such as AngleFragment, to provide different shaped fragments.
  6. Each Fragment will have a timer, similar to those in Missile, to time it out and have it destroy itself.
  7. Fleets probably has a role to play in getting the explosion going.

That’s surely more than enough plan to be wrong.

I want information. In particular, I want to review how collisions with the ship work, to get a sense for how we might get the explosion going.

There is just this method in Ship:

class Ship:
    def destroyed_by(self, attacker, ships):
        if self in ships: ships.remove(self)

The rest of the logic must be in the collision code. We’ll hunt that down.

class Collider:
    def check_individual_collisions(self, attackers, targets):
        for target in targets.flyers.copy():
            for attacker in attackers.flyers.copy():
                if self.mutual_destruction(target, targets, attacker, attackers):
                    break

    def mutual_destruction(self, target, targets, attacker, attackers):
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            attacker.destroyed_by(target, attackers)
            target.destroyed_by(attacker, targets)
            return True
        else:
            return False

We collide individual groups pairwise, and when two flyers come in range, we fetch the score, and tell each participant that it has been destroyed by the other. We pass each participant his own collection, so that he can remove himself.

I’m curious whether anyone does anything with the attacker. A quick scan tells me that no one does refer to their attacker, not even to send a nasty note. Let’s see if we can change the signature readily. I change one and ten tests fail. Oh well, let’s finish the job.

I change all of ship, asteroid, saucer, and missile. Still ten tests.

PyCharm over-converted these guys, making me think that I should have done this differently. Let’s roll back and try again, even though I’m green now.

Darn! Either way, PyCharm can’t handle the job correctly. I’ve managed it manually, and now I don’t like the name of the method, because it’s used like this:

    def mutual_destruction(self, target, targets, attacker, attackers):
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            attacker.destroyed_by(attackers),
            target.destroyed_by(targets),
            return True
        else:
            return False

I could imagine that we could just do the remove here but if we remember what we’re up to, we’re going to put some intelligence into Ship to create a fuss. We could rename the method to destroyed or you_are_dead.

After all this, I’m going to put it back the way it was, with the attacker being passed in. I’m not sure why, just a feeling. Roll back again.

Was this a waste of time?

Well, maybe, but two important things happened. First, I learned a bit about PyCharm’s abilities (and inabilities) in changing signatures. Second, I got a look at the code in what I thought would be a better form … and I didn’t like it. I can’t even say why, but if you had just asked me whether to make the change removing the attacker, I’d have said yes and there we’d be.

I don’t even think we’ll need attacker. I just like it better. It seems more right.

Exploding

But we’re here to do the explosion. We see now that it should start here:

class Ship:
    def destroyed_by(self, attacker, ships):
        if self in ships: ships.remove(self)
        # create explosion

We know that we want to populate the ExplosionFleet (which we don’t even have yet). So, I think we know that we want access to the Fleets instance here.

Oh, no, another signature change. Anyway I’m getting good at it.

Back to Collider, to see where we stand regarding Fleets.

class Collider:
    def __init__(self, space_objects):
        self.space_objects = space_objects
        self.score = 0

What are these space objects whereof you speak?

class Game:
    def process_collisions(self):
        collider = Collider(self.fleets)
        self.score += collider.check_collisions()

Perfect. Rename that member in Collider.

class Collider:
    def __init__(self, fleets):
        self.fleets = fleets
        self.score = 0

Since we have the fleets member, we can just pass it in as another argument to destroyed_by. Here we go with the signature change again.

Again I had to do it manually. PyCharm is just not up to the task. I wonder if type hints would have helped.

class Collider:
    def mutual_destruction(self, target, targets, attacker, attackers):
        if self.within_range(target, attacker):
            self.score += target.score_for_hitting(attacker)
            self.score += attacker.score_for_hitting(target)
            attacker.destroyed_by(target, attackers, self.fleets)
            target.destroyed_by(attacker, targets, self.fleets)
            return True
        else:
            return False

Now we have fleets going to everyone when they collide. No one cares, yet. Commit: Fleets instance passed by Collider to everyone’s destroyed_by method.

It is 0918 and I started at 0833. Not much progress, but we’re on the road now.

Plan

How shall we proceed now? How about this:

  1. TDD a trivial ExplosionFleet;
  2. Give it a trivial explosion for testing;
  3. Cause Fleets to have an ExplosionFleet instance;
  4. Put a method on Fleets, maybe explosion_at(position);
  5. Forward that to the ExplosionFleet.

Something like that, anyway.

    def test_explosion_fleet(self):
        fleet = ExplosionFleet()
        explosion = fleet.flyers
        fleet.explode_at(u.CENTER)
        assert explosion

That should drive out a bit of code:

class ExplosionFleet(Fleet):
    def __init__(self):
        super().__init__([])

    def explode_at(self, position):
        for i in range(5):
            self.flyers.append(Missile(position, Vector2(0,0), [], []))

It just popped into my mind to put missiles in there. The test passes.

I want to give the missiles some velocity and plug this in, just to see what it does.

    def explode_at(self, position):
        for i in range(5):
            angle = random.randrange(360)
            velocity = Vector2(u.MISSILE_SPEED,0).rotate(angle)
            self.flyers.append(Missile(position, velocity, [], []))

So then …

class Ship:
    def destroyed_by(self, attacker, ships, fleets):
        if self in ships: ships.remove(self)
        fleets.explosion_at(self.position)

class Fleets:
    def __init__(self, asteroids=None, missiles=None, saucers=None, saucer_missiles=None, ships=None):
        asteroids = asteroids if asteroids is not None else []
        missiles = missiles if missiles is not None else []
        saucers = saucers if saucers is not None else []
        saucer_missiles = saucer_missiles if saucer_missiles is not None else []
        ships = ships if ships is not None else []
        self.fleets = (
            AsteroidFleet(asteroids),
            MissileFleet(missiles, u.MISSILE_LIMIT),
            SaucerFleet(saucers),
            MissileFleet(saucer_missiles, u.SAUCER_MISSILE_LIMIT),
            ShipFleet(ships),
            ExplosionFleet())

    def explosion_at(self, position):
        self.explosions.explosion_at(position)

    @property
    def explosions(self):
        return self.fleets[5]

Tests are failing, how odd. I’ve been inconsistent in naming. I change to explosion_at throughout. Now I can try this in the game, just for the fun of it.

I’ll try to get a video of this but all I see now is that when I crash into an asteroid a bunch of missiles do fly out but the game crashes. I need to put better score tables into my Missiles. With that in place, I get this film. It’s basically working!

missile emitted when ship crashes

I don’t want to commit this with those missiles in there, but if I remove them my test will fail. And I’m wondering why those missiles seemed to make things explode. Oh, I know. Collider considers all the fleets:

class Collider:
    def check_collisions(self):
        for pair in itertools.combinations(self.fleets.fleets, 2):
            self.check_individual_collisions(pair[0], pair[1])
        return self.score

We’d like our ExplosionFleet not to participate in collisions. We should ask Fleets to provide colliding_fleets:

    def check_collisions(self):
        for pair in itertools.combinations(self.fleets.colliding_fleets, 2):
            self.check_individual_collisions(pair[0], pair[1])
        return self.score

That breaks a test, which is good, because I was going to feel guilty for not writing one to drive this code out. Now a new property:

    @property
    def colliding_fleets(self):
        return (self.asteroids, self.missiles, self.saucers, self.saucer_missiles, self.ships)

Tests are green. Now my ship explosion should fragment but not kill things. Perfect.

Now, we could actually just live with this implementation, maybe make the missiles move more slowly. If we do that, I think we can commit this.

I set the speed slower:

    def explosion_at(self, position):
        for i in range(5):
            angle = random.randrange(360)
            velocity = Vector2(u.MISSILE_SPEED/3,0).rotate(angle)
            self.flyers.append(Missile(position, velocity, [0,0,0], [0,0]))

The missiles move slowly, looking semi-decent. Green. Commit: interim ship explosion is five dots looking suspiciously like missiles, but harmless.

Reflection

I’m glad I noticed that the fake explosion missiles were killing things, because when I fixed that, I realized that the effect wasn’t great but is clearly a visible step toward a decent explosion. And the test passes, so we could commit and ship the product.

I’m not moving very rapidly, it’s now 1012 and I started at 0833. That’s OK, things take as long as they take.

I think we have the basic structure in place now, and what we need is to create our Fragment object and populate the explosion with those.

A fragment is a lot like a Missile, in that it flies for a period of time and then dies. Let’s see how Missiles work, we can use them as a template.

The relevant bits seem to be:

class Missile:
    def __init__(self, position, velocity, missile_score_list, saucer_score_list):
        self.score_list = missile_score_list
        self.saucer_score_list = saucer_score_list
        self.position = position.copy()
        self.velocity = velocity.copy()
        self.radius = 2
        self.timer = Timer(u.MISSILE_LIFETIME, self.timeout)

    def move(self, delta_time):
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

    def tick_timer(self, delta_time, missiles):
        self.timer.tick(delta_time, missiles)

    def tick(self, delta_time, fleet, _fleets):
        self.tick_timer(delta_time, fleet)
        self.move(delta_time)
        return True

    def timeout(self, missiles):
        missiles.remove(self)

Oh and this is relevant:

    def draw(self, screen):
        pygame.draw.circle(screen, "white", self.position, 4)

The draw is relevant because it shows me that the fragments can just draw directly to the screen, rather than blit surfaces like asteroids, ship, and saucer do. That will be quite useful, making our Fragment drawing much simpler.

Let’s TDD some fragments. I think I’ll make a new test file for this.

class TestFragments():
    def test_frag(self):
        frag = Fragment()
        assert frag

class Fragment():
    def __init__(self):
        pass

Green. Commit: initial Fragment class. Let’s see how often I can commit this thing.

I’m not sure I like this but:

    def test_frag_random_angle(self):
        angle = 180
        frag = Fragment(angle=angle)
        assert frag.velocity == Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)

I plan to allow for parameters like angle to be passed in, optionally, and set randomly otherwise. That should let me test a bit.

class Fragment():
    def __init__(self, angle=None):
        angle = angle if angle else random.randrange(360)
        self.velocity = Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)

I want to make sure that I understand angle to be in degrees.

    def test_frag_random_angle(self):
        angle = 180
        frag = Fragment(angle=angle)
        assert frag.velocity == Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        assert frag.velocity.x == -u.FRAGMENT_SPEED

Now let’s see about timing. I don’t think there is a test for the missile timing out. Let’s do one for Fragment anyway.

    def test_frag_timeout(self):
        frag = Fragment()
        fleets = Fleets()
        frags = fleets.explosions
        frags.append(frag)
        frags.tick(0.1, fleets)
        assert frags
        frags.tick(u.FRAGMENT_LIFETIME, fleets)
        assert not frags

This ought to be about right. I need the new u variable. Then I need the timer and the tick.

class Fragment():
    def __init__(self, angle=None):
        angle = angle if angle else random.randrange(360)
        self.velocity = Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)

    def tick(self, delta_time, fragments, _fleets):
        self.timer.tick(delta_time, fragments)
        # self.move(delta_time)

Green. I should be committing. Commit: fragment times out after u.FRAGMENT_LIFETIME.

Let’s do move, which I sketched in but commented out.

As I write this test I realize that I’m not giving the fragments a position. They really do need one.

class Fragment():
    def __init__(self, position, angle=None):
        angle = angle if angle else random.randrange(360)
        self.position = position
        self.velocity = Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)

Now back to my test …

    def test_fragment_move(self):
        frag = Fragment(position = u.CENTER, angle=0)
        frag.move(0.1)
        assert frag.position == Vector2(u.FRAGMENT_SPEED*0.1, 0)

And:

    def move(self, delta_time):
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position

Even after I fix the test, it fails:

    def test_fragment_move(self):
        frag = Fragment(position=u.CENTER, angle=0)
        frag.move(0.1)
        assert frag.position == u.CENTER + Vector2(u.FRAGMENT_SPEED*0.1, 0)

The message is:

Expected :<Vector2(520.333, 512)>
Actual   :<Vector2(512.291, 520.328)>

X and Y seem reversed … and slightly wrong. Odd.

Oh. Python “Truthy”.

class Fragment():
    def __init__(self, position, angle=None):
        angle = angle if angle else random.randrange(360)
        self.position = position
        self.velocity = Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)

If angle is 0, Python treats it as Falsy and we get a random angle. Fix that.

class Fragment():
    def __init__(self, position, angle=None):
        angle = angle if angle is not None else random.randrange(360)
        self.position = position
        self.velocity = Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)

Green. Commit: Fragment understands move.

Let’s give our Fragment a draw method, just a line. We’ll spiff it up as we go.

    def draw(self, screen):
        begin = self.position + Vector2(-3,0)
        end = self.position + Vector2(3,0)
        pygame.draw.line(screen, "red", begin, end, 3)

I don’t expect to like this but I expect to be able to see it, after I do this:

    def tick(self, delta_time, fragments, _fleets):
        self.timer.tick(delta_time, fragments)
        self.move(delta_time)

And this:

    def explosion_at(self, position):
        for i in range(8):
            angle = random.randrange(360)
            fragment = Fragment(position=position, angle=angle)
            self.flyers.append(fragment)

fragment cloud expanding

It’s 1125, time for a break. We can commit this: ship fragments are now eight small red lines.

Let’s quickly sum up and take a break. I’ll probably return to this this afternoon.

Summary

Things really went quite smoothly. The Fleets and Fleet objects served well, Fleets as a place from which to trigger the explosion, and a new Fleet type to create the explosion Fragments. We have a rudimentary Fragment object in place, which is good enough except for needing to be more interesting than a ring of parallel lines.

We did six commits, which is about one every 30 minutes, a bit less frequent than I’d like, and certainly not “tiny” intervals, but good enough.

I was disappointed in PyCharm’s inability to do a good job of changing signature on my destroyed_by method, but in a duck-typing language, I think those are the breaks. I might have done better with global replace, but just ticking through them worked well enough.

The tests were useful throughout, both my new ones and the existing ones, which caught occasions where I didn’t provide the right arguments to things.

We did extend the destroyed_by method to pass the Fleets instance, but that’s necessary to give the ship the chance to trigger its explosion. I think passing the parameter down was far better than having some kind of global or raising an exception or something like that.

Overall, it went well, We’re down to the graphical aspects, which should be easy enough.

See you next time, probably this afternoon!