Python Asteroids on GitHub

I think I’ll move to replace another Fleet subclass. People I trust are tired of Asteroids. I briefly address that concern.

Received Wisdom

I am reliably informed that I may have just about mined all the value from solo arcade game code. I don’t actually believe that, but I can certainly grasp the notion that some people might be interested in something more collaborative than what I do here, something addressing the very important issues of a group of people working together to get a larger thing done.

Thoughtful Response

There are reasons why I do this, and most of them are about me. Yes, I offer what I do here as gifts to be taken if the reader is interested, and you don’t even have to buy me an iced chai. But I do it because I enjoy pushing code into different shapes, the same as one might enjoy pushing clay into different shapes or crafting tables of wood, or drawing pictures of cats. I enjoy the doing of the code and the writing of these words.

And it distracts me. By my lights, the world has problems that trouble me deeply, and I can see no effective action that I could take to make it better. Rather than dwell on concerns I cannot address, I “retreat to the known” and work on things that I can improve, even though doing that may not make the world a better place.

However, every now and again, someone engages with me, around Asteroids, or around some similar thing, and they let me know that they think about what I’ve said, and are taking it up, or dedicating their lives to showing that I am wrong, and in those cases, by golly, maybe I did make the world a little bit better place. Such moments bring me joy.

I have the luxury, at my advanced age, and at least for now, that I can do what I want to do. Somehow, I’ve found a balance between ambition (I have very little left), money (I have enough to live comfortably if without a vast stable of sports cars), time (plenty day to day, who knows how many days left), and brain power (enough to do this).

So, I choose things that strike my interest. If a different activity strikes my interest, you can be sure that I’ll try it. And if not, well, maybe I’ll convert this Asteroids game to Space Invaders.

Asteroids Again

I enjoyed yesterday’s moves, moving firing logic into the Saucer and getting rid of the MissileFleet class. So today, I think we’ll try another one. It’s Sunday, so maybe there will be too little time, but we’ll at least learn enough to start over.

There are just two remaining Fleet subclasses, SaucerFleet and ShipFleet. These have similar purposes. SaucerFleet creates a new saucer N seconds after the last one has left, and ShipFleet creates a new ship K seconds after the last one was destroyed, if there is another one available, and if it is safe to do so.

We see immediately which of these is probably easier, so we’ll work on the saucer case.

Back literally a while ago, we replaced the AsteroidFleet with a WaveMaker object that creates new waves of asteroids after all the asteroids are gone. This behavior sounds a lot like the behavior of SaucerFleet and ShipFleet. Let’s see how WaveMaker works.

class WaveMaker(Flyer):
    def __init__(self):
        self.saw_asteroids = None
        self.timer = Timer(u.ASTEROID_DELAY, self.create_asteroids)
        self.asteroid_count = 2

    def create_asteroids(self, fleets):
        self.asteroid_count += 2
        if self.asteroid_count > 11:
            self.asteroid_count = 11
        for i in range(self.asteroid_count):
            fleets.add_asteroid(Asteroid())

    def begin_interactions(self, fleets):
        self.saw_asteroids = False

    def interact_with_asteroid(self, asteroid, fleets):
        self.saw_asteroids = True

    def tick(self, delta_time, fleet, fleets):
        if not self.saw_asteroids:
            self.timer.tick(delta_time, fleets)

    def interact_with(self, other, fleets):
        pass

    def draw(self, screen):
        pass

The object uses begin and interact to determine whether there are any asteroids. On tick, if there are none, it ticks its timer. When the timer expires, it calls create_asteroids, which does what it says.

It’s tempting to try to generalize WaveMaker. However, I am morally strong today and while we might later refactor to remove common elements, I’m not going to do that. Instead, I’m going to create a new object, might as well call it SaucerMaker, and if we get enough duplication, maybe we’ll do something about it.

Are there tests for WaveMaker? A few. Encouraged by that fact, I’ll create some tests for SaucerMaker and build it up.

class TestSaucerMaker:
    def test_exists(self):
        SaucerMaker()

Just about the simplest test I can imagine, and enough to drive out the class. PyCharm helps and this passes the test:

class SaucerMaker(Flyer):
    def interact_with(self, other, fleets):
        pass

    def draw(self, screen):
        pass

    def tick(self, delta_time, fleet, fleets):
        pass

I think I’ll try to write a fairly comprehensive test and we’ll see how much trouble I get into.

    def test_creates_saucer(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.add_flyer(SaucerMaker())
        assert not fi.saucers
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        assert fi.saucers

That kind of says it all, or at least most of it. But the test is passing. Why? Because the Fleets is creating a SaucerFleet. We need not to do that. Change this:

class Fleets:
    def __init__(self, asteroids=(), missiles=(), saucers=(), saucer_missiles=(), ships=()):
        self.fleets = dict(
            asteroids=Fleet([]),
            saucers=SaucerFleet([]),
            ships=ShipFleet([]),
            flyers=Fleet([]))
        ...

To this:

class Fleets:
    def __init__(self, asteroids=(), missiles=(), saucers=(), saucer_missiles=(), ships=()):
        self.fleets = dict(
            asteroids=Fleet([]),
            saucers=Fleet([]),
            ships=ShipFleet([]),
            flyers=Fleet([]))

Two tests fail. We must already have a saucer emergence test. That’s nice. What is it?

Looks familiar:

class TestFleets:
    def test_saucer_spawn(self):
        fleets = Fleets()
        fleets.tick(0.1)
        assert not FI(fleets).saucers
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        assert FI(fleets).saucers

I think I’ll remove this one since mine clearly does the same thing and mine has a SaucerMaker in it. Now to make mine work.

class SaucerMaker(Flyer):
    def __init__(self):
        self._timer = Timer(u.SAUCER_EMERGENCE_TIME, self.create_saucer)
        self._saucer_gone = True

    def create_saucer(self, fleets):
        fleets.add_saucer(Saucer())

    def begin_interactions(self, fleets):
        self._saucer_gone = True

    def interact_with_saucer(self, saucer, fleets):
        self._saucer_gone = False


    def interact_with(self, other, fleets):
        pass

    def draw(self, screen):
        pass

    def tick(self, delta_time, fleet, fleets):
        if self._saucer_gone:
            self._timer.tick(delta_time, fleets)

Test passes. I think we’re good. I’d like to have another test to prove that it won’t just create more saucers.

As soon as I start doing that I realize that my test isn’t really exercising the interactions. So:

    def test_creates_saucer(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.add_flyer(SaucerMaker())
        interactor = Interactor(fleets)
        assert not fi.saucers
        interactor.perform_interactions()
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        assert fi.saucers

    def test_does_not_create_too_many(self):
        fleets = Fleets()
        fi = FI(fleets)
        fleets.add_flyer(SaucerMaker())
        interactor = Interactor(fleets)
        assert not fi.saucers
        interactor.perform_interactions()
        fleets.tick(u.SAUCER_EMERGENCE_TIME)
        assert len(fi.saucers) == 1
        interactor.perform_interactions()
        fleets.tick(u.SAUCER_EMERGENCE_TIME/2)
        assert len(fi.saucers) == 1

I think this is enough. I could do a longer test showing the saucer going away and coming back, but I think we’re good. I will try the game, but first I need to add a SaucerMaker at startup.

class Game:

    available_ship = Ship(Vector2(0, 0))
    available_ship._angle = 90

    def __init__(self, testing=False):
        self.delta_time = 0
        self.init_pygame_and_display(testing)
        self.fleets = Fleets()
        self.fleets.add_scorekeeper(ScoreKeeper(testing))
        self.fleets.add_wavemaker(WaveMaker())
        self.fleets.add_flyer(SaucerMaker())
        self.running = not testing

Game works. Commit: Convert game to use SaucerMaker, not SaucerFleet.

Now we can remove SaucerFleet. Done. Commit: SaucerFleet class removed.

Nice. Let’s sum up.

Summary

That all went smoothly, which isn’t too much of a surprise, as there wasn’t much to it, and it followed an existing pattern in our Flyers, setting a flag on begin, clearing it on interact_with_, and ticking the timer on tick if the flag so indicates.

In the SaucerMaker, the flag is _saucer_gone, while in WaveMaker it is saw_asteroids. The result is that SaucerMaker’s code is, I think, a bit more clear in tick:

class SaucerMaker(Flyer):
    def tick(self, delta_time, fleet, fleets):
        if self._saucer_gone:
            self._timer.tick(delta_time, fleets)

class WaveMaker(Flyer):
    def tick(self, delta_time, fleet, fleets):
        if not self.saw_asteroids:
            self.timer.tick(delta_time, fleets)

Let’s change WaveMaker to be more like SaucerMaker, using private members, and reversing the sense of the flag. I have to reverse a couple of tests, and in the class it’s now like this:

class WaveMaker(Flyer):
    def __init__(self):
        self._need_asteroids = None
        self._timer = Timer(u.ASTEROID_DELAY, self.create_asteroids)
        self._number_to_create = 2

    def create_asteroids(self, fleets):
        self._number_to_create += 2
        if self._number_to_create > 11:
            self._number_to_create = 11
        for i in range(self._number_to_create):
            fleets.add_asteroid(Asteroid())

    def begin_interactions(self, fleets):
        self._need_asteroids = True

    def interact_with_asteroid(self, asteroid, fleets):
        self._need_asteroids = False

    def tick(self, delta_time, fleet, fleets):
        if self._need_asteroids:
            self._timer.tick(delta_time, fleets)

Better. Commit: refactor WaveMaker renaming for clarity, reverse sense of flag.

So. Moved a feature to a Flyer, deleted an Whole Entire Class, made the campground a bit better than we found it. A good result and pleasant relaxed work, as it should be.

See you next time!