Python Asteroids on GitHub

The saucer’s missiles just emanate from center screen. Time to fix that. I’m not sure how to test it. Oh, wait, I have an idea.

The saucer is intended to fire missiles randomly 3/4 of the time and targeted 1/4 of the time. Up there at the blurb, I wasn’t sure how to deal with testing, given the desired randomness, but the answer is pretty obvious, I think: We’ll have a firing function that is given a random value, which will be provided from above. We can test the lower level one, and trust our random number generator, which is perfectly legitimate.

The random missile will be given an angle between 0 and 360, and the missile’s velocity should be the sum of two values: the saucer’s then current velocity, and the standard missile velocity in the direction of aim. We can test that. Let’s try.

    def test_random_missile_velocity(self):
        saucer = Saucer()
        saucer.velocity = Vector2(100, 200)
        zero_angle_velocity = Vector2(u.MISSILE_SPEED, 0)
        missile = saucer.missile_at_angle(0)
        assert missile.velocity == saucer.velocity + zero_angle_velocity

That starting test is more than enough to get me started on the new method missile_at_angle. Let’s fake it till we make it:

    def missile_at_angle(self, degrees):
        missile_velocity = Vector2(u.MISSILE_SPEED, 0)
        return Missile(self.position, self.velocity + missile_velocity)

This should be passing. And it is. Let’s extend the test. Or, would it be better to do another. Let’s do little tiny ones. But wait, we’re green, we can commit: initial version of saucer.missile_at_angle.

I need constant practice making small tests and frequent commits. Now another test.

    def test_random_missile_velocity_90(self):
        saucer = Saucer()
        saucer.velocity = Vector2(100, 200)
        zero_angle_velocity = Vector2(u.MISSILE_SPEED, 0)
        missile = saucer.missile_at_angle(90)
        assert missile.velocity == saucer.velocity + zero_angle_velocity.rotate(90)

That calls for this:

    def missile_at_angle(self, degrees):
        missile_velocity = Vector2(u.MISSILE_SPEED, 0).rotate(degrees)
        return Missile(self.position, self.velocity + missile_velocity)

And we are green. Commit: saucer.missile_at_angle sets correct velocity.

The missile’s position is set to the saucer’s position. You’d kind of expect the missile to materialize a bit outside the saucer. Let’s test for that.

    def test_random_missile_position_90(self):
        saucer = Saucer()
        saucer.position = Vector2(123, 456)
        missile = saucer.missile_at_angle(90)
        expected_offset = Vector2(2*saucer.radius, 0).rotate(90)
        assert missile.position == saucer.position + expected_offset

And to code:

    def missile_at_angle(self, degrees):
        missile_velocity = Vector2(u.MISSILE_SPEED, 0).rotate(degrees)
        offset = Vector2(2*self.radius, 0).rotate(degrees)
        return Missile(self.position + offset, self.velocity + missile_velocity)

We’re green. Commit: saucer.missile_at_angle computes correct starting offset.

Now let’s plug that in and see how it looks. We have this already:

class Saucer:
    @staticmethod
    def create_missile():
        return Missile(u.CENTER, Vector2(70, 70))

We say this:

    def create_missile(self):
        degrees = random.random()*360.0
        return self.missile_at_angle(degrees)

I expect this to look pretty good. It does:

saucer firing

Commit: saucer fires random missiles in play. missiles kill ship, not asteroids.

I think I’d like the saucer to be more dangerous than it is, and I think it also looks better if its missiles do kill asteroids. For that to work without adding to the score, saucer missiles need different scoring than regular ones.

How does that work?

class Missile:
    def __init__(self, position, velocity):
        self.score_list = [100, 50, 20] # or [0, 0, 0] if you're a saucer missile?
        self.position = position.copy()
        self.velocity = velocity.copy()
        self.radius = 2
        self.time = 0

What a nice helpful comment I’ve provided for myself. I note also those calls to copy: I vaguely recall that if we just copied in someone else’s position or velocity, bad things happened: the two objects were linked on that value and a change to one affected the other.

That’s how it goes with mutable objects if you pass them around. We could create new velocity and position variables all the time but at sixty times a second that seems unwise. Copying for safety seems better.

I wonder if it’s really true that v += v1 mutates v. Let’s test that.

    def test_vectors_mutate(self):
        v1 = Vector2(1, 2)
        v1_original = v1
        assert v1 is v1_original
        v2 = Vector2(3, 4)
        v1 += v2
        assert v1 is v1_original
        v1 = v1 + v2
        assert v1 is not v1_original

We have learned that Vector2 += mutates, and that writing the plus out longhand does not.

We could have the convention that we never use +=, and avoid the copy, but trusting ourselves never to use += on a vector is risky. We’ll tuck this learning away but stick with the copy.

We were talking about saucer damage to asteroids. Let’s write a test, and let’s assume that we want two new class methods on missile, fromShip and fromSaucer.

Here’s a test:

    def test_missile_scoring(self):
        p = Vector2(12, 34)
        v = Vector2(56, 78)
        ship_missile = Missile.from_ship(p,v)
        assert ship_missile.score_list == u.MISSILE_SCORE_LIST
        ssaucer_missile = Missile.from_saucer(p,v)
        assert ssaucer_missile.score_list == [0, 0, 0]

With this test, I’m also asking myself to move the missile score list over to u.

We code:

u.py:
MISSILE_SCORE_LIST = [100, 50, 20]

class Missile:
    def __init__(self, position, velocity, score_list):
        self.score_list = score_list
        self.position = position.copy()
        self.velocity = velocity.copy()
        self.radius = 2
        self.time = 0

    @classmethod
    def from_ship(cls, position, velocity):
        return cls(position, velocity, u.MISSILE_SCORE_LIST)

    @classmethod
    def from_saucer(cls, position, velocity):
        return cls(position, velocity, [0, 0, 0])

14 tests are failing, because I didn’t do the signature change in a way that alerted python to help me. They’ll all be pretty simple to fix. The new test is working. I’ll just find and fix all the references to Missile.

Basically pasting .from_ship fixes all the tests. Commit: from_ship and from_saucer constructors for Missile.

Now I am sure that saucer missiles would not score if they were allowed to hit asteroids. That’s controlled here:

class Collider:
    def check_collisions(self):
        self.check_individual_collisions(self.ships, self.asteroids)
        self.check_individual_collisions(self.asteroids, self.missiles)
        self.check_individual_collisions(self.ships, self.missiles)
        self.check_individual_collisions(self.saucer_missiles, self.ships)
        return self.score

We should check saucer missiles against asteroids, and we should check the saucer against missiles, and also ship against saucer.

I don’t see a decent way to test whether those are done. I guess we could set up tests with different pairs of objects … but let me just put these in and see what happens.

asteroids vs saucer_missiles works in the game. Let’s do the others.

    def check_collisions(self):
        self.check_individual_collisions(self.ships, self.asteroids)
        self.check_individual_collisions(self.saucers, self.asteroids)
        self.check_individual_collisions(self.asteroids, self.missiles)
        self.check_individual_collisions(self.asteroids, self.saucer_missiles)
        self.check_individual_collisions(self.ships, self.missiles)
        self.check_individual_collisions(self.ships, self.saucer_missiles)
        self.check_individual_collisions(self.ships, self.saucers)
        return self.score

This works exactly as advertised. It’s kind of hard to be sure whether all the combinations are in there. There are five kinds of things, asteroids, ships, saucers, missiles, and saucer missiles. 5 choose 2 is 5 factorial over 2 factorial times 3 factorial, or 10. If we allow saucer missiles and missiles to destroy each other, we should have ten combinations above, because everything else is mutually destructive.

I think we’ll do this with a loop or something but first I want to see what’s going on here.

I carefully enumerate all pairs:

        self.check_individual_collisions(self.asteroids, self.missiles)  # 1
        self.check_individual_collisions(self.asteroids, self.saucers)  # 2
        self.check_individual_collisions(self.asteroids, self.saucer_missiles)  # 3
        self.check_individual_collisions(self.asteroids, self.ships)  # 4
        self.check_individual_collisions(self.missiles, self.saucer_missiles)  # 5
        self.check_individual_collisions(self.missiles, self.saucers)  # 6
        self.check_individual_collisions(self.missiles, self.ships)  # 7
        self.check_individual_collisions(self.saucer_missiles, self.saucers)  # 8
        self.check_individual_collisions(self.saucer_missiles, self.ships)  # 9
        self.check_individual_collisions(self.saucers, self.ships)   # 10

That’s everything against everything else. It seems to work fine except that the screen is awfully tight. I think we need to do some scaling.

Can we do this code in a more reasonable way? It turns out that Python has a module called itertools that includes combinations(arr, r) to produce all the combinations of the array taken r at a time. Let’s test that.

    def test_combinations(self):
        things = [1, 2, 3, 4, 5]
        combinations = itertools.combinations(things, 2)
        count = 0
        for pair in combinations:
            count += 1
        assert count == 10

That passes. Let’s just do this:

class Collider:
    def __init__(self, asteroids, missiles, saucers, saucer_missiles, ships):
        self.asteroids = asteroids
        self.missiles = missiles
        self.saucers = saucers
        self.saucer_missiles = saucer_missiles
        self.ships = ships
        self.movers = [asteroids, missiles, saucers, saucer_missiles, ships]
        self.score = 0

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

This works. We can simplify Collider:

class Collider:
    def __init__(self, asteroids, missiles, saucers, saucer_missiles, ships):
        self.movers = [asteroids, missiles, saucers, saucer_missiles, ships]
        self.score = 0

Still good, of course. Commit: Collider collides all combinations of objects.

So that’s kind of interesting.

I think this is enough for now. Let’s sum up.

Summary

We just started out to cause saucer missiles to emanate from the saucer at the proper velocity. Testing in game led me to prioritize making the missiles kill the ship, and that led to them killing the asteroids, because it looks more reasonable, and that led to looking at the combinations of what kills what.

A little thinking caused me to accept that any object colliding with an object of any other type will cause mutual destruction, which led to putting all combinations through the collision checking. A little searching came up with itertools.combinations, and a test showed me how to use it.

Now the screen is wonderfully chaotic as everything shoots down everything:

chaotic game

We do not score any points when we shoot the saucer. And there are other saucer story slices in store:

  • Possibly randomize saucer zig time. [NEW]
  • Saucer scores 200 in initial size, more when small. (Look it up.)
  • 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 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]
  • Saucer fires a missile every so often (1 second? 1/2?)
  • Saucer only has two missiles on screen at a time;
  • Saucer missiles kill asteroids;
  • Saucer does destroy asteroids when it hits them;
  • Saucer missiles do not add to score;

We’re pretty close to the saucer being done. I’m a little concerned with the scoring issue but I think we can probably handle it by setting different saucer score values in the missile, much as with the score list for asteroids. We’ll find out soon.

I am pleased. We’ll do a bit of code review real soon now and see what needs improvement, but overall I feel pretty good about code quality.

See you next time!