Python Asteroids on GitHub

I’ve done what I intended with the billiards diversion. Let’s see what we need here in Asteroidsville. Just small stuff. PyCharm impresses me greatly today.

Way back in article 22, I had this list of possible stories. I’ll cross off two more, multiple waves and Score.

  1. Ship should explode;
  2. Ship should reappear after an interval, at center screen;
  3. Ship should appear only when center screen is relatively safe;
  4. Saucer, with all its works and all its pomps;
  5. Multiple waves of asteroids;
  6. Score should appear on screen;
  7. Number of remaining ships should appear on screen.
  8. Tests.

What else? There’s hyperspace, I’ll add that and reorder the list. I think I’ll put tests at the top just as a reminder. And from “Jira”, my keyboard tray, we have:

  • Smart collections?
  • Game object
  • Break loop in asteroid vs ship
  • Ship.active delete
  • Main loop
  • Test collision score

Let me explain what I think those may mean. That’s the trouble with my yellow sticky notes Jira, it doesn’t contain color glossy photos with circles and arrows and a paragraph on the back of each one explaining what each one is.

Smart collections?
The game uses regular Python lists. These are not really very clever at all. It is often—perhaps usually—a good idea to create smarter collections to wrap the basic lists, with methods more suitable to the particular situation. We’d have to look at that to see what benefit we might get, but at the time I wrote that I must have had something in mind.
Game Object
The main game code is all top-level data and functions. It would probably be better to make an object to hold more of the data. Relatedly, new Jira entry:
Globals etc
There are a lot of global values and I don’t think I’m managing them as well as I might. I don’t know what the ideal Python practice is, and there’s no particular organization to a lot of it.
Break loop
I think this is fixed. The issue was that the asteroid-ship collision loop continued checking even after the ship had been destroyed. We’ll look at that.
Ship.active delete
The ship had an active flag. It’s not supposed to be used any more, but there may still be references to it.
Main loop
In addition to probably wanting to be part of a game object, the main loop could use review to look at the order it does things, and for general improvement. It’s pretty ad-hoc, things tossed in there more or less randomly.
Test collision score
There are tests around collisions but at the time that sticky was written, none that checked that scoring happens when it should and not when it shouldn’t. I’m not sure of the status now.

The Plan

Let’s clean up some of the small stuff and see where that leads us. I’ll strike out the things on the list that we fix up, if we fix up any.

Break loop in asteroid vs ship

def check_asteroids_vs_ship():
    for the_ship in ships.copy():  # there's only one, do it first
        for asteroid in asteroids.copy():
            asteroid.collide_with_attacker(the_ship, ships, asteroids)
            if not ships:
                set_ship_timer(u.SHIP_EMERGENCE_TIME)
                return

Perfect. That one’s done, we return if the ships collection is empty, meaning that the collision removed it. Toss the sticky, strike out the line on the list.

Ship.active delete

Search for active, I guess. There are two tests, and three other touches to ship.active:

def move_everything(ship, dt):
    if ship.active: ship.move(dt)
    for asteroid in asteroids:
        asteroid.move(dt)
    for missile in missiles:
        missile.move(dt)

class Ship:
    def __init__(self, position):
        self.score_list = [0, 0, 0]
        self.position = position.copy()
        self.velocity = Vector2(0, 0)
        self.active = True
        ...

    def collide_with_asteroid(self, asteroid):
        if asteroid.withinRange(self.position, self.radius):
            self.active = False

collide_with_asteroid is only used in the two tests. Let’s check those:

class TestCollisions:
    def test_far_away(self):
        ship = Ship(Vector2(0, 0))
        asteroid = Asteroid(2, Vector2(200, 200))
        ship.collide_with_asteroid(asteroid)
        assert ship.active

    def test_close_enough(self):
        ship = Ship(Vector2(0, 0))
        asteroid = Asteroid(2, Vector2(50, 50))
        ship.collide_with_asteroid(asteroid)
        assert not ship.active
    ...

Those are useless, but I’d like to have something testing this. We have a sticky about test collision score. Let’s add a new one: Add collision tests, ship-asteroid etc.

With that done, remove those two tests, then the method referred to, then the only remaining reference to active, in init.

Uh oh, we have that code in move. I almost forgot that. That can refer to ships. It if has contents, we should move the ship. We could even do this:

def move_everything(dt):
    for ship in ships:
        ship.move(dt)
    for asteroid in asteroids:
        asteroid.move(dt)
    for missile in missiles:
        missile.move(dt)

That changed the signature of move_everything, so this changes:

def main_loop():
    global running, ship, clock, delta_time, game_over_surface, game_over_pos
    game_init()
    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        check_ship_spawn(ship, ships, delta_time)
        check_next_wave(delta_time)
        control_ship(ship, delta_time)

        for missile in missiles.copy():
            missile.update(missiles, delta_time)

        move_everything(delta_time)  # <=== changed
        check_collisions()
        draw_everything()
        if game_over: draw_game_over()
        pygame.display.flip()
        delta_time = clock.tick(60) / 1000
    pygame.quit()

Tests are green, I should be committing this stuff. Test the game too. I am a terrible pilot, as usual. Commit: remove ship.active flag.

This reminds me, I promised to put this thing on GitHub. Make a sticky and a line item.

The only remaining really small items are tests, “Test collision score” and “Add collision tests, ship-asteroid, etc”. Let’s review those tests. There are a lot of them. I’ll just show the names to begin with, then we’ll see if we want to explore any in detail.

Tests

class TestCollisions:
    def test_respawn_ship(self):
    def test_respawn_count(self):
    def test_safe_to_emerge_hates_missiles(self):
    def test_safe_to_emerge_hates_close_asteroids(self):
    def test_firing_limit(self):
    def test_dual_kills(self):
    def test_score_list(self):

What might we check?

I think it comes down to this: a missile-asteroid collision scores, and ship-asteroid, saucer-asteroid do not. Let’s see how we can test this. It should be easy to set up the collisions. I’ll try missile-asteroid first.

    def test_missile_asteroid_scores(self):
        asteroid = Asteroid(2, Vector2(0, 0))
        missile = Missile(Vector2(0, 0), Vector2(0, 0))
        missiles = [missile]
        asteroid.collide_with_attacker(missile, missiles, asteroid)
        assert not missiles

This test isn’t complete but I expected this much to run. It does not. Here’s the function:

    def collide_with_attacker(self, attacker, attackers, asteroids):
        if self.withinRange(attacker.position, attacker.radius):
            if attacker in attackers: attackers.remove(attacker)
            u.score += attacker.score_list[self.size]
            self.split_or_die(asteroids)

I see that I should be passing in the asteroids collection, not the asteroid. Fix that, but I doubt that solves my problem.

I’ve just learned an interesting fact. Vector2(0,0) is considered to be false. I guess this goes along with Python’s interesting notion that 0.0 is false. The result of this is that the asteroid thinks it has no position and gives itself a random edge position.

    def test_missile_asteroid_scores(self):
        pos = Vector2(100,100)
        asteroid = Asteroid(2, pos)
        print("position", asteroid.position)
        asteroids = [asteroid]
        missile = Missile(pos, Vector2(0, 0))
        missiles = [missile]
        asteroid.collide_with_attacker(missile, missiles, asteroids)
        assert not missiles

Test runs now. Let’s fix up asteroid’s init:

    def test_create_asteroid_at_zero(self):
        asteroid = Asteroid(2, Vector2(0, 0))
        assert asteroid.position == Vector2(0, 0)

class Asteroid:
    def __init__(self, size=2, position=None):
        self.size = size
        if self.size not in [0,1,2]:
            self.size = 2
        self.radius = [16, 32, 64][self.size]
        self.position = position if position is not None else Vector2(0, random.randrange(0, u.SCREEN_SIZE))
        angle_of_travel = random.randint(0, 360)
        self.velocity = u.ASTEROID_SPEED.rotate(angle_of_travel)
        self.offset = Vector2(self.radius, self.radius)
        self.surface = SurfaceMaker.asteroid_surface(self.radius * 2)

Now let’s see about the Score.

    def test_missile_asteroid_scores(self):
        u.score = 0
        pos = Vector2(100, 100)
        asteroid = Asteroid(2, pos)
        print("position", asteroid.position)
        asteroids = [asteroid]
        missile = Missile(pos, Vector2(0, 0))
        missiles = [missile]
        asteroid.collide_with_attacker(missile, missiles, asteroids)
        assert not missiles
        assert u.score == 20

Green. Let’s commit: New test, plus asteroids can be created at origin.

Now let’s collide a ship and assert no score:

    def test_missile_ship_does_not_score(self):
        u.score = 0
        pos = Vector2(100, 100)
        asteroid = Asteroid(2, pos)
        print("position", asteroid.position)
        asteroids = [asteroid]
        ship = Ship(pos)
        ships = [ship]
        asteroid.collide_with_attacker(ship, ships, asteroids)
        assert not ships
        assert u.score == 0

SOme copy-pasta and handy PyCharm renaming went on there. And the test passes. I’m slightly surprised. How does that work?

            u.score += attacker.score_list[self.size]

Ah. I bet that the ship’s score list is all zero.

class Ship:
    def __init__(self, position):
        self.score_list = [0, 0, 0]

So that’s good. And I’ve got a stubbed Saucer class and it has the list as well. Should we test it anyway? OK, why not, it’ll be easy.

    def test_missile_saucer_does_not_score(self):
        u.score = 0
        pos = Vector2(100, 100)
        asteroid = Asteroid(2, pos)
        print("position", asteroid.position)
        asteroids = [asteroid]
        saucer = Saucer(pos)
        saucers = [saucer]
        asteroid.collide_with_attacker(saucer, saucers, asteroids)
        assert not saucers
        assert u.score == 0

I had to beef up my Saucer stub a bit.

class Saucer:
    def __init__(self, position=None):
        if position is not None: self.position = position
        self.score_list = [0, 0, 0]
        self.radius = 20

I don’t think that will stick, but it’ll do to keep the test in place and if I deviate too much from the stub, the test may help me notice.

I think I’m satisfied with these tests, as they go directly to the method that checks collisions between asteroids and other stuff.

What about other collisions? What do we check now:

def check_collisions():
    check_asteroids_vs_ship()
    check_asteroids_vs_missiles()

So far that’s all. We could check ship vs missile but that can’t happen, at least not yet. The missiles time out before they can hit the ship, although with hyperspace it’s possible to re-materialize in front of one. I’ll make a Jira for it.

I need a commit: Tests show saucer vs asteroid and ship vs asteroid do not score.

Now for GitHub.

Wow, did PyCharm ever make that easy!

Under the Git menu, it had “GitHub>Share on GitHub”, when I selected that, I got a form to fill in with the desired repo name, and to select who was sharing (me). There was nothing in that field, because PyCharm didn’t know my account. I clicked “Add Account”, and it took me to a JetBrains page, where I was logged in, and asked me to authorize. That took me to a GitHub login, which I filled in … and the job was done.

Python Asteroids on GitHub

I think we’re done for a Saturday morning. Here’s my complete Jira as it stands.

  • Smart collections?
  • Game object
  • Main loop
  • Globals etc
  • Hyperspace
  • Saucer
  • Display Available Ships
  • missile vs ship (hyperspace)
  • Ship.active delete
  • Break loop in asteroid vs ship
  • Test collision score
  • Add collision tests, ship-asteroid etc.
  • GitHub

I’ve scrapped the stickies that are out-ge-stricken. Let’s sum up.

Summary

No big steps today, just some pretty straightforward tests and small improvements. I’d like to address the main loop and globals next, I think.

One interesting learning, that makes sense but was not obvious: Vector2(0,0) is falsy or falsey or falsie or however they spell that non word. Reminds me … never mind.

I am quite pleased and impressed with how easy PyCharm made getting the project up on GitHub. Well done JetBrains!

See you next time!