Python Asteroids on GitHub

I need another distraction. What’s in “Jira”?1, 2

jira - keyboard tray sticky notes

Some of them are done and should be thrown away:

  • Avail Ships size/position?
  • Insert Quarter? Move Up?
  • Magic Numbers: Available Ships
  • main loop
  • saucer

Some possible candidate things:

  • Copy! removes
  • Debug keys
  • doctest?
  • Events ala Rickard Lindberg
  • hyperspace
  • make pygame template w/ Game class
  • Move -> update (tick?)
  • Nullables per James Shore
  • star field
  • tick returns True is bad.

James Shore has done some interesting work Testing with Nullables, but I’ve not studied it enough to know what to do with it. Rickard Lindberg’s events idea, mentioned back in #40 is also something that I’d have to think about. Honestly, I don’t feel that we need it: our tick loop is reasonable at the current scale.

Doctest is an interesting python testing framework that embeds tests as comments in your code. It’s kind of interesting but I really don’t think I want the code that littered with commentary.

Making a template might be useful to me but is unlikely to be even as interesting as what I do write about, so if I do that, it’ll be on “my own time”.

The “Copy! removes” one refers to the need to copy the object lists before we iterate them, in case of removes. Fleets now iterate like this:

class Fleet:
    def __iter__(self):
        return self.flyers.copy().__iter__()

So that form of looping is covered. Let’s glance at Collider:

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

Ah. That should no longer be necessary. I think I mentioned that once before and didn’t fix it because I was on another mission. We can surely do this:

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

Green and good. Commit: eliminate redundant copy in Collider, iterate fleets directly.

So that’s done.

The tick function all return True or False, the idea having been that they’d return false if they wanted the game to end, but that’s not how we do it.

Let’s remove that:

class Fleets:
    def tick(self, delta_time):
        all_true = True
        for fleet in self.fleets:
            if not fleet.tick(delta_time, self):
                all_true = False
        return all_true

This will break tests, because we no longer want this “feature”. Change to:

    def tick(self, delta_time):
        for fleet in self.fleets:
            fleet.tick(delta_time, self)

Test breaks:

    def test_fleets_tick(self):
        asteroids = [FakeFlyer()]
        missiles = [FakeFlyer()]
        saucers = [FakeFlyer()]
        saucer_missiles = [FakeFlyer()]
        ships = [FakeFlyer()]
        fleets = Fleets(asteroids, missiles, saucers, saucer_missiles, ships)
        result = fleets.tick(0.1)
        assert result

I see no other function for this, so remove it. Green, but I want to drill down into the other implementations of tick. Could commit now. Sure, if we can, we should. Commit: Fleets.tick no longer returns T/F.

Now in Fleet:

class Fleet:
    def tick(self, delta_time, fleets):
        result = True
        for flyer in self:
            result = flyer.tick(delta_time, self, fleets) and result
        return result

That gets similar treatment to Fleets:

    def tick(self, delta_time, fleets):
        for flyer in self:
            flyer.tick(delta_time, self, fleets)

Green. Commit: Fleet.tick no longer returns T/F.

Now the individual implementors of tick. I’ll only report anything interesting.

It turns out that Fragment.tick already was not returning True. I think there used to be code somewhere that interpreted None as True? Or maybe, since no one really cared, it didn’t matter. Anyway, that one is now correct.

Asteroid, Missile, Saucer, and Ship all got their unconditional return True removed. Commit: No more return T/F from tick.

Why was that worth doing, you ask, because it wasn’t hurting anyone? Well, when you read the code there in Fleets.tick you see a lot going on to get the T/F right and you wonder why. When you see tick functions returning True, you wonder why. Now you don’t wonder any more. That’s a good thing: there are better things to wonder about.

Jira ticket done. I’m left with:

  • Debug Keys
  • Hyperspace
  • move -> update (tick?)
  • Star Field

Let’s do a rudimentary hyperspace. If I recall correctly, hyperspace:

  • is triggered by space key
  • has a non-zero chance of destroying the ship immediately
  • immediately moves the ship to a random location and rotation

The chance of immediate destruction is a complicated function of the number of asteroids, if I’m not mistaken. Let’s do the easy bit.

I wonder what the easy bit is. Let’s look at how controls, and especially firing, works in the ship.

class Ship:
    def control_motion(self, delta_time, missiles):
        if not pygame.get_init():
            return
        keys = pygame.key.get_pressed()
        if keys[pygame.K_f]:
            self.turn_left(delta_time)
        if keys[pygame.K_d]:
            self.turn_right(delta_time)
        if keys[pygame.K_j]:
            self.power_on(delta_time)
        else:
            self.power_off()
        if keys[pygame.K_k]:
            self.fire_if_possible(missiles)
        else:
            self._can_fire = True

    def fire_if_possible(self, missiles):
        if self._can_fire and missiles.fire(self.create_missile):
            self._can_fire = False

There’s that little bit of stuff with can_fire, because we don’t want to fire more than once per key down. Same deal with hyperspace.

Let’s write a test for this feature.

    def test_hyperspace(self):
        ship = Ship(Vector2(100,100))
        ship.enter_hyperspace_if_possible()
        position = ship.position
        angle = ship._angle
        assert position != Vector2(100,100) or angle != 90
        ship.enter_hyperspace_if_possible()
        assert ship.position == position and ship._angle == angle

The idea here is that ship will move to a random position and angle the first time so the odds are good the test will pass. Oh, wait, I have a better idea: let’s start it where it can never go:

    def test_hyperspace(self):
        impossible = Vector2(-5, -5)
        impossible_angle = 370
        ship = Ship(impossible)
        ship._angle = impossible_angle
        ship.enter_hyperspace_if_possible()
        position = ship.position
        angle = ship._angle
        assert position != impossible and angle != impossible_angle
        ship.enter_hyperspace_if_possible()
        assert ship.position == position and ship._angle == angle

That should be just fine. Now the method:

    def enter_hyperspace_if_possible(self):
        if can_enter_hyperspace:
            x = random.randint(u.SCREEN_SIZE)
            y = random.randint(u.SCREEN_SIZE)
            a = random.randint(360)
            self.move_to(Vector2(x, y))
            self._angle = a
            self.can_enter_hyperspace = False

Now that wants the flag:

class Ship(Flyer):
    def __init__(self, position):
        super().__init__()
        self.radius = 25
        self._location = MovableLocation(position, Vector2(0, 0))
        self._can_enter_hyperspace = True
        self._can_fire = True
        self._angle = 0
        self._acceleration = u.SHIP_ACCELERATION
        self._accelerating = False
        ship_scale = 4
        ship_size = Vector2(14, 8)*ship_scale
        self._ship_surface, self._ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)

Editing __init__ reminds me to mark the flag “private”:

    def enter_hyperspace_if_possible(self):
        if self._can_enter_hyperspace:
            x = random.randint(u.SCREEN_SIZE)
            y = random.randint(u.SCREEN_SIZE)
            a = random.randint(360)
            self.move_to(Vector2(x, y))
            self._angle = a
            self._can_enter_hyperspace = False

Curiously, the test is still failing. My calls to randint are wrong. Generally I’ve been using randrange. I’ll do that.

    def enter_hyperspace_if_possible(self):
        if self._can_enter_hyperspace:
            x = random.randrange(u.SCREEN_SIZE)
            y = random.randrange(u.SCREEN_SIZE)
            a = random.randrange(360)
            self.move_to(Vector2(x, y))
            self._angle = a
            self._can_enter_hyperspace = False

We’re green. Commit: hyperspace test works. Not yet in controls.

Now for controls:

        if keys[pygame.K_SPACE]:
            self.enter_hyperspace_if_possible()
        else:
            self._can_enter_hyperspace = True

Unless I miss my guess, I should now be able to hyperspace out.

I suddenly remember that there is supposed to be a “hyperspace refractory period”, a time delay before you can enter hyperspace again. I’ll make a Jira for that and destruction on entry.

Now to test in game:

As soon as I do that, I remember that you’re supposed to get a random velocity also. Otherwise you’re just sitting there in space.

Fix:

class Ship:
    def enter_hyperspace_if_possible(self):
        if self._can_enter_hyperspace:
            x = random.randrange(u.SCREEN_SIZE)
            y = random.randrange(u.SCREEN_SIZE)
            a = random.randrange(360)
            self.move_to(Vector2(x, y))
            dx = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
            dy = random.randrange(u.SHIP_HYPERSPACE_MAX_VELOCITY)
            self.accelerate_to(Vector2(dx, dy))
            self._angle = a
            self._can_enter_hyperspace = False

u:
SHIP_HYPERSPACE_MAX_VELOCITY = SPEED_OF_LIGHT // 6

That’s pretty decent in play. The ship drifts but not too wildly. Commit: hyperspace works, no refractory period, no entry failure yet.

I think we have an article here. Let’s sum up.

Summary

We have a feature! It has been a while, and it’s a useful one. I might actually be able to clear four asteroids with four ships given hyperspace.

Perhaps you noticed that control_motion is passed the missiles fleet, so that it can add a missile if it needs to:

    def control_motion(self, delta_time, missiles):

For the hyperspace bad luck feature, we’ll need to pass in either the number of asteroids, or the asteroids collection. The number makes more sense, because all the feature will care about is how many there are. If there are fewer, your chances of dying are higher because you should be a better pilot. We’ll deal with that when the time comes.

The feature went in smoothly, except for my general inability to remember which random function I use. Test-driving it surely helped. I have not tested the specifics of how it selects a random location, angle, and velocity, but that seems reasonable to me. Another, better, person would probably inject random numbers and see if they come back out or something.

Aside from our wonderful new feature, we ticked off some Jira:

  • Copy! removes
  • Debug keys
  • doctest?
  • Events ala Rickard Lindberg
  • hyperspace
  • make pygame template w/ Game class
  • Move -> update (tick?)
  • Nullables per James Shore
  • star field
  • tick returns True is bad.

And added two more, for hyperspace failure and refractory period.

Not bad. I think we’ll ship this and the 4 AM article now.

At the last moment, I decide that I should test that hyperspace returns a non-zero velocity:

    def test_hyperspace(self):
        impossible = Vector2(-5, -5)
        impossible_angle = 370
        ship = Ship(impossible)
        ship._angle = impossible_angle
        ship.enter_hyperspace_if_possible()
        position = ship.position
        angle = ship._angle
        assert position != impossible and angle != impossible_angle
        assert ship._location.velocity != Vector2(0,0)
        ship.enter_hyperspace_if_possible()
        assert ship.position == position and ship._angle == angle

Commit: include velocity check in hyperspace test. Takes but a moment and worth doing, to document what must happen if for no other reason.

Ship it!

See you next time, I hope!



  1. Why do I need a distraction, you’re wondering? Personal problem worrying me. Doubtless everything will be OK. 

  2. Jira is my clever name for the sticky notes collection on my keyboard tray: