Python Asteroids on GitHub

Let’s begin by getting rid of the Explosion Flyer, at least the Flyer part of it.

The Explosion object is a Flyer that is created, tossed into the mix, which then immediately removes itself on its first cycle, adding in Fragment Flyers instead. This isn’t particularly wasteful, and I rather like it, but it adds complexity to the mix that just makes it that little bit harder to understand. So we’ll remove it.

A few days back, we removed the Coin object, which did a similar thing, replacing itself with the objects needed to start the game in play mode or attract mode.

We have some Coin tests written for that occasion. They’re all similar so here’s one to look at:

    def test_quarter(self):
        fleets = Fleets()
        fi = FI(fleets)
        Coin.quarter(fleets)
        assert fi.saucermakers
        assert fi.scorekeepers
        assert fi.thumpers
        assert fi.wavemakers
        assert fi.shipmakers

Explosion currently looks like this:

class Explosion(Flyer):

    @classmethod
    def from_ship(cls,position):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        guy = Fragment.astronaut_fragment
        return cls(position, [vee, guy, simple, simple, simple, simple, simple])

    def __init__(self, position, fragment_factory_methods):
        self.position = position
        self.fragment_factory_methods = fragment_factory_methods

    def tick(self, delta_time, fleets):
        fleets.remove(self)
        self.explosion_at(self.position, fleets)

    def explosion_at(self, _position, fleets):
        random.shuffle(self.fragment_factory_methods)
        how_many = len(self.fragment_factory_methods)
        for i in range(how_many):
            factory_method = self.fragment_factory_methods[i]
            base_direction = 360 * i / how_many
            self.make_fragment(factory_method, base_direction, fleets)

    def make_fragment(self, factory_method, base_direction, fleets):
        twiddle = random.randrange(-20, 20)
        fragment = factory_method(position=self.position, angle=base_direction+twiddle)
        fleets.append(fragment)

We’d best look at some of Fragment to get the drift of it as well:

class Fragment(Flyer):

    @classmethod
    def v_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        side_1 = [line, Vector2(-7, 5), Vector2(7, 0)]
        side_2 = [line, Vector2(7, 0), Vector2(-7, -5)]
        return cls(position, angle, speed_mul, [side_1, side_2])

    def __init__(self, position, angle=None, speed_mul=None, fragments=None):
        angle = angle if angle is not None else random.randrange(360)
        self.position = position
        speed_mul = speed_mul if speed_mul is not None else random.uniform(0.25, 0.5)
        self.velocity = speed_mul*Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.theta = random.randrange(0, 360)
        self.delta_theta = random.uniform(180, 360)*random.choice((1, -1))
        self.timer = Timer(u.FRAGMENT_LIFETIME)
        self.fragments = fragments

    @classmethod
    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        head = ["head", Vector2(0, 24), 8, 2]
        body_bottom = Vector2(0, 2)
        body = [line, Vector2(0, 16), body_bottom]
        left_leg = [line, Vector2(-5, -16), body_bottom]
        right_leg = [line, Vector2(5, -16), body_bottom]
        arm = [line, Vector2(-9, 10), Vector2(9, 10)]
        return cls(position, angle, speed_mul, [head, body, arm, left_leg, right_leg])

If we were to drill into Fragment further, we’d see that it has a tiny little “language” that draws a line or a circle, and that a given Fragment is a collection of those commands that draws the desired shape.

When it ticks, the Explosion iterates the list of factory methods, creating however many Fragment instances are needed, and dumps them into fleets.

We want to do that without putting Explosion into fleets first. Let’s do a test:

    def test_saucer_explosion(self):
        fleets = Fleets()
        fi = FI(fleets)
        Explosion.from_saucer(fleets)
        assert len(fi.fragments) == 7

Test fails, as intended.

Expected :7
Actual   :0

Not the right failure. Test isn’t quite right: the class methods expect a position.

    def test_saucer_explosion(self):
        fleets = Fleets()
        fi = FI(fleets)
        pos = Vector2(100, 100)
        Explosion.from_saucer(pos, fleets)
        assert len(fi.fragments) == 7

Better, now it can’t make the call. We extend explosion:

    @classmethod
    def from_saucer(cls,position, fleets=None):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        explosion = cls(position, [vee, vee, simple, vee, simple, vee, simple])
        if fleets:
            explosion.explosion_at(position, fleets)
            explosion.fragment_factory_methods = []
        return explosion

I did something a bit tricky here. If fleets is provided, we immediately call the explosion_at method, which will immediately emit the explosion into fleets. Then we empty the Explosion. That ensures that if fleets is provided but the Explosion is also dumped into fleets, it will not produce a second explosion.

We’ll be working to make sure that doesn’t happen but for now we should be safer.

And the test is green. That makes me want to look for from_saucer senders and hook them into fleets.

class Saucer(Flyer):
    def explode(self, fleets):
        player.play("bang_large", self._location)
        player.play("bang_small", self._location)
        fleets.remove(self)
        fleets.append(Explosion.from_saucer(self.position))

We change that:

    def explode(self, fleets):
        player.play("bang_large", self._location)
        player.play("bang_small", self._location)
        fleets.remove(self)
        Explosion.from_saucer(self.position, fleets)

In game play we should get the explosion just as before, through the new mechanism.

It takes a while for the saucer to make a mistake but it does explode properly.

Let’s commit: Saucer Explosion is not added to mix, just adds fragments directly.

Now a test for the from_ship, similar to the other.

    def test_ship_explosion(self):
        fleets = Fleets()
        fi = FI(fleets)
        pos = Vector2(100, 100)
        Explosion.from_ship(pos, fleets)
        assert len(fi.fragments) == 7

As before, from_ship needs to deal with fleets.

    @classmethod
    def from_ship(cls,position, fleets=None):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        guy = Fragment.astronaut_fragment
        explosion = cls(position, [vee, guy, simple, simple, simple, simple, simple])
        if fleets:
            explosion.explosion_at(position, fleets)
            explosion.fragment_factory_methods = []
        return explosion

Test runs. Find sender and fix it up.

class Ship(Flyer):
    def explode(self, fleets):
        player.play("bang_large", self._location)
        fleets.remove(self)
        Explosion.from_ship(self.position, fleets)

Again we check in Game. Works a treat. Commit: Ship Explosion is not added to mix, just adds fragments directly.

There are a couple of test senders of from_ship and from_saucer, I think.

This one is now redundant and wrong. Remove the file entirely.

class TestExplosion:
    def test_explosion(self):
        fleets = Fleets()
        explosion = Explosion.from_ship(u.CENTER)
        fleets.append(explosion)
        explosion.tick(0.1, fleets)
        mix = fleets.all_objects
        for o in mix:
            print(o, o is Fragment)
        assert explosion not in mix
        fragments = [f for f in mix if isinstance(f, Fragment)]
        assert len(fragments) == 7

Removing it breaks a couple of other tests somehow. Nooo … I broke two tests and didn’t see it. I’ve committed two broken tests. Arrgh. I need some way to stop commits when I’m red.

This is in TestHyperspaceGenerator:

    def test_failure(self):
        fleets = Fleets()
        impossible = Vector2(-5, -9)
        ship = Ship(impossible)
        fi = FI(fleets)
        fleets.append(ship)
        hg = HyperspaceGenerator(ship)
        hg.recharge()
        hg.press_button(0, fleets, 45)  # fail = roll > 44 + tally
        assert fi.explosions

Change that to check for fragments. The other has the same issue, fixed:

    def test_saucer_missile_kills_ship(self):
        pos = Vector2(100, 100)
        ship = Ship(pos)
        missile = Missile.from_saucer(pos, Vector2(0, 0))
        fleets = Fleets()
        fleets.append(ship)
        fleets.append(missile)
        fi = FI(fleets)
        assert fi.missiles
        assert fi.ships
        fleets.perform_interactions()
        assert not fi.missiles
        assert not fi.ships
        assert fi.fragments

Commit: fix red tests SORRY ..

Now as I was saying, remove that test_explosion file. Green. Commit: remove useless test.

Now checking for more from_saucer or from_ship senders. All gone.

Now explosion need not be a flyer and we can trim it. I remove all this:

    def interact_with(self, other, fleets):
        other.interact_with_explosion(self, fleets)

    def interact_with_asteroid(self, asteroid, fleets):
        pass

    def interact_with_explosion(self, explosion, fleets):
        pass

    def interact_with_fragment(self, fragment, fleets):
        pass

    def interact_with_missile(self, missile, fleets):
        pass

    def interact_with_saucer(self, saucer, fleets):
        pass

    def interact_with_ship(self, ship, fleets):
        pass

    def draw(self, screen):
        pass

    def tick(self, delta_time, fleets):
        fleets.remove(self)
        self.explosion_at(self.position, fleets)

Commit: Explosion is no longer a Flyer subclass. Much simplified.

I think we have some duplication and perhaps some other improvements to deal with. Here’s explosion:

class Explosion:

    @classmethod
    def from_ship(cls,position, fleets=None):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        guy = Fragment.astronaut_fragment
        explosion = cls(position, [vee, guy, simple, simple, simple, simple, simple])
        if fleets:
            explosion.explosion_at(position, fleets)
            explosion.fragment_factory_methods = []
        return explosion

    @classmethod
    def from_saucer(cls,position, fleets=None):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        explosion = cls(position, [vee, vee, simple, vee, simple, vee, simple])
        if fleets:
            explosion.explosion_at(position, fleets)
            explosion.fragment_factory_methods = []
        return explosion

    def __init__(self, position, fragment_factory_methods):
        self.position = position
        self.fragment_factory_methods = fragment_factory_methods

    def explosion_at(self, _position, fleets):
        random.shuffle(self.fragment_factory_methods)
        how_many = len(self.fragment_factory_methods)
        for i in range(how_many):
            factory_method = self.fragment_factory_methods[i]
            base_direction = 360 * i / how_many
            self.make_fragment(factory_method, base_direction, fleets)

    def make_fragment(self, factory_method, base_direction, fleets):
        twiddle = random.randrange(-20, 20)
        fragment = factory_method(position=self.position, angle=base_direction+twiddle)
        fleets.append(fragment)

We can now require the fleets parameter, skip the ifs, eliminate the clearing of explosion, and eliminate the returns:

    @classmethod
    def from_ship(cls,position, fleets):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        guy = Fragment.astronaut_fragment
        explosion = cls(position, [vee, guy, simple, simple, simple, simple, simple])
        explosion.explosion_at(position, fleets)

    @classmethod
    def from_saucer(cls,position, fleets):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        explosion = cls(position, [vee, vee, simple, vee, simple, vee, simple])
        explosion.explosion_at(position, fleets)

We have the position and should either pass it or use it but not both. Remove the parameter and let the object use it, which it does already:

class Explosion:

    @classmethod
    def from_ship(cls,position, fleets):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        guy = Fragment.astronaut_fragment
        explosion = cls(position, [vee, guy, simple, simple, simple, simple, simple])
        explosion.explosion_at(fleets)

    @classmethod
    def from_saucer(cls,position, fleets):
        simple = Fragment.simple_fragment
        vee = Fragment.v_fragment
        explosion = cls(position, [vee, vee, simple, vee, simple, vee, simple])
        explosion.explosion_at(fleets)

    def __init__(self, position, fragment_factory_methods):
        self.position = position
        self.fragment_factory_methods = fragment_factory_methods

    def explosion_at(self, fleets):
        random.shuffle(self.fragment_factory_methods)
        how_many = len(self.fragment_factory_methods)
        for i in range(how_many):
            factory_method = self.fragment_factory_methods[i]
            base_direction = 360 * i / how_many
            self.make_fragment(factory_method, base_direction, fleets)

    def make_fragment(self, factory_method, base_direction, fleets):
        twiddle = random.randrange(-20, 20)
        fragment = factory_method(position=self.position, angle=base_direction+twiddle)
        fleets.append(fragment)

Let’s rename explosion_at to … explode. I’ll spare you the reprint. Commit: rename explosion_at to explode.

I think we’re good. Let’s sum up.

Summary

We have removed upwards of 27 lines from a file that is now only 37 lines long. We have removed an entire class from Flyer.

And what we haven’t done, yet, is remove all the implementations of interact_with_explosion. I forgot until now.

There are 14 of them, in all the other flyers. Could have avoided that by not making it abstract. Anyway remove them all. Commit: remove abstract method interact_with_explosion and all implementors.

Every one of those implementations was pass, by the way, a tragic waste of complexity. Modified 14 files, removed 42 more lines of useless code.

Anyway that went well. I’m glad I remembered to remove those methods. We need a way to remove for of them, especially for the objects that don’t really interact much if at all. Need some thinking for that.

A small but noticeable improvement. I am well pleased.

See you next time!