Python Asteroids on GitHub

I remark on some things I learned from Rickard, and then get started on Saucer. I’m so successful that I had to split the article into two to spare the reader.

I’m not sure just what to do this morning. Here’s the current Jira1 list.

  • Smart collections?
  • Main loop
  • Hyperspace
  • Saucer
  • Available Ship Display tuning
  • make PyGame template w game class
  • Star field
  • Magic Number available ships
  • Combine ship v missile; pull up collision and message both?
  • Game object
  • Globals etc
  • u.score -> game

Reflecting on what I might do, I’m reminded that I watched one of Rickard Lindberg’s videos and read some of his writings, and I learned a few things. The first was that he uses vim as his development platform, and uses it well. Another, from the sound of it, was that he apparently has a mechanical keyboard. Very retro cool.

More important was that he uses doctest as his testing engine. For those few readers who don’t already know (I didn’t) doctest lets you embed test-focused lines in text comments right in your modules, and when you run doctest it extracts those, runs them and checks them against provided answers. Very interesting and probably keeps you focused on tests because they are right there in front of you. I’ve tried it briefly in Pythonista, the iPad Python, and it might be useful there because it doesn’t have pytest.

Another thing is that’s he’s following James Shore’s Testing with Nullables approach. That was the first time I’ve seen that in action. I think it’s worth looking into and I may add it to my list of things to experiment with and write about. I’d definitely recommend checking out Rickard’s video, and James’s courses are brilliant.

And, finally, Rickard was using an events package which let him have an interesting separation of his PyGame-dependent code from the actual game loop. I’m not sure if he was using a provided library or one that he wrote: the source is certainly there in his GitHub repo. I’ve used an event approach in other projects, but not in Asteroids. One possible path for this project will be to replace PyGame, which I do not love, with pyglet, which I do not expect to love. Converting to an event-driven design might be one good way to plug in a different graphics package.

All very interesting, and I’ve added three new stickies to Jira (q.v.), Events à la Rickard Lindberg, doctest ??, and Nullables per James Shore? But those are not for today

What’s For Today?

I think that for today, we should work on the Saucer. We do have a test or two and a rudimentary Saucer class that passes:

    def test_score_list(self):
        ship = Ship(u.CENTER)
        assert ship.score_list == [0, 0, 0]
        missile = Missile(u.CENTER, Vector2(0, 0))
        assert missile.score_list == [100, 50, 20]
        saucer = Saucer()
        assert saucer.score_list == [0, 0, 0]

    def test_asteroid_saucer_does_not_score(self):
        game = Game(True)
        pos = Vector2(100, 100)
        asteroid = Asteroid(2, pos)
        print("position", asteroid.position)
        asteroids = [asteroid]
        saucer = Saucer(pos)
        saucers = [saucer]
        collider = Collider(asteroids, [], saucers, [])
        collider.mutual_destruction(asteroid, asteroids, saucer, saucers)
        assert not saucers
        assert game.score == 0

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

    def destroyed_by(self, attacker, saucers):
        if self in saucers: saucers.remove(self)

    def score_against(self, _):
        return 0

I’m not sure that the list of zeros is the right list for the saucer score, but I wrote the test to ensure that I had the list there to be accessed.

Let’s move the class to its own file, called, surprisingly, saucer.py. A quick cut-paste and an import in the test file and we’re green. Commit: added saucer.py.

Let’s think what the saucer’s story slices seem to be.

  • Saucer appears every n (=7) seconds;
  • Saucer start left to right, alternates right to left;
  • Saucer does (does not?) destroy asteroids when it hits them;
  • Saucer fires a missile very so often (1 second? 1/2?)
  • Saucer only has two missiles on screen at a time;
  • Saucer missiles do (do not) kill asteroids;
  • Saucer missiles do not add to score;
  • Saucer is smaller if score > 10000 or some value;
  • Saucer scores 200 in initial size, more when small. (Look it up.)
  • Saucer zig-zags every 1.5 seconds, left, straight, straight, right, randomly;
  • Saucer will fire across border if it’s a better shot;
  • Saucer fires randomly 3/4 of the time, targeted 1/4 of the time;

I had forgotten what a nasty little monster the saucer is. I’ll have to relearn the requirements and there may be a lot to figure out, like the targeting. I won’t hesitate to review other versions that I’ve written. We call that “reuse”.

I think we’ll start by creating the saucer’s surface. We’ll use SurfaceMaker, which looks like this:

raw_ship_points = [Vector2(-3.0, -2.0), Vector2(-3.0, 2.0), Vector2(-5.0, 4.0),
                   Vector2(7.0, 0.0), Vector2(-5.0, -4.0), Vector2(-3.0, -2.0)]
raw_flare_points = [Vector2(-3.0, -2.0), Vector2(-7.0, 0.0), Vector2(-3.0, 2.0)]

raw_rocks = [
    [
        Vector2(4.0, 2.0), Vector2(3.0, 0.0), Vector2(4.0, -2.0),
        Vector2(1.0, -4.0), Vector2(-2.0, -4.0), Vector2(-4.0, -2.0),
        Vector2(-4.0, 2.0), Vector2(-2.0, 4.0), Vector2(0.0, 2.0),
        Vector2(2.0, 4.0), Vector2(4.0, 2.0),
    ],
    [
        Vector2(2.0, 1.0), Vector2(4.0, 2.0), Vector2(2.0, 4.0),
        Vector2(0.0, 3.0), Vector2(-2.0, 4.0), Vector2(-4.0, 2.0),
        Vector2(-3.0, 0.0), Vector2(-4.0, -2.0), Vector2(-2.0, -4.0),
        Vector2(-1.0, -3.0), Vector2(2.0, -4.0), Vector2(4.0, -1.0),
        Vector2(2.0, 1.0)
    ],
    [
        Vector2(-2.0, 0.0), Vector2(-4.0, -1.0), Vector2(-2.0, -4.0),
        Vector2(0.0, -1.0), Vector2(0.0, -4.0), Vector2(2.0, -4.0),
        Vector2(4.0, -1.0), Vector2(4.0, 1.0), Vector2(2.0, 4.0),
        Vector2(-1.0, 4.0), Vector2(-4.0, 1.0), Vector2(-2.0, 0.0)
    ],
    [
        Vector2(1.0, 0.0), Vector2(4.0, 1.0), Vector2(4.0, 2.0),
        Vector2(1.0, 4.0), Vector2(-2.0, 4.0), Vector2(-1.0, 2.0),
        Vector2(-4.0, 2.0), Vector2(-4.0, -1.0), Vector2(-2.0, -4.0),
        Vector2(1.0, -3.0), Vector2(2.0, -4.0), Vector2(4.0, -2.0),
        Vector2(1.0, 0.0)
    ]
]


class SurfaceMaker:

    next_shape = 0

    @staticmethod
    def adjust(point, center_adjustment, scale_factor):
        return (point + center_adjustment) * scale_factor

    @staticmethod
    def ship_surfaces(ship_size):
        raw_points_span = Vector2(14, 8)
        raw_points_offset = raw_points_span / 2
        scale_factor = ship_size.x / raw_points_span.x
        ship_surface = SurfaceMaker.create_scaled_surface(
            ship_size, raw_points_offset, scale_factor, raw_ship_points)
        accelerating_surface = SurfaceMaker.create_scaled_surface(
            ship_size, raw_points_offset, scale_factor, raw_ship_points, raw_flare_points)
        return ship_surface, accelerating_surface

    @staticmethod
    def asteroid_surface(actual_size):
        raw_rock_points = SurfaceMaker.get_next_shape()
        raw_points_span = 8
        raw_points_offset = Vector2(4, 4)
        scale = actual_size / raw_points_span
        room_for_fat_line = 2
        surface_size = actual_size + room_for_fat_line
        surface = SurfaceMaker.create_scaled_surface((surface_size, surface_size),
                                                     raw_points_offset, scale, raw_rock_points)
        return surface

    @staticmethod
    def get_next_shape():
        rock_shape = raw_rocks[SurfaceMaker.next_shape]
        SurfaceMaker.next_shape = (SurfaceMaker.next_shape + 1) % 4
        return rock_shape

    @staticmethod
    def create_scaled_surface(dimensions, offset, scale_factor, *point_lists):
        surface = pygame.Surface(dimensions)
        surface.set_colorkey((0, 0, 0))
        for point_list in point_lists:
            SurfaceMaker.draw_adjusted_lines(offset, point_list, scale_factor, surface)
        return surface

    @staticmethod
    def draw_adjusted_lines(offset, point_list, scale_factor, surface):
        adjusted = [SurfaceMaker.adjust(point, offset, scale_factor) for point in point_list]
        pygame.draw.lines(surface, "white", False, adjusted, 3)

I see that I didn’t bring over the saucer’s points. I’ll scarf them from a Kotlin version, hold on a moment.

private val saucerPoints = listOf(
    Point(-2.0, 1.0), Point(2.0, 1.0), Point(5.0, -1.0),
    Point(-5.0, -1.0), Point(-2.0, -3.0), Point(2.0, -3.0),
    Point(5.0, -1.0), Point(2.0, 1.0), Point(1.0, 3.0),
    Point(-1.0, 3.0), Point(-2.0, 1.0), Point(-5.0, -1.0),
    Point(-2.0, 1.0)
)

We want something similar but with Vector2 and square brackets. A quick edit and:

raw_saucer_points = [
    Vector2(-2.0, 1.0), Vector2(2.0, 1.0), Vector2(5.0, -1.0),
    Vector2(-5.0, -1.0), Vector2(-2.0, -3.0), Vector2(2.0, -3.0),
    Vector2(5.0, -1.0), Vector2(2.0, 1.0), Vector2(1.0, 3.0),
    Vector2(-1.0, 3.0), Vector2(-2.0, 1.0), Vector2(-5.0, -1.0),
    Vector2(-2.0, 1.0)
]

Now we need to make the surface. I’ll follow the pattern. I have little recollection of how to use SurfaceMaker but I trust myself somewhat. So I’ll copy the ship and edit it.

    @staticmethod
    def saucer_surface(saucer_size):
        raw_points_span = Vector2(10, 6)
        raw_points_offset = raw_points_span / 2
        scale_factor = saucer_size.x / raw_points_span.x
        saucer_surface = SurfaceMaker.create_scaled_surface(
            saucer_size, raw_points_offset, scale_factor, raw_ship_points)
        return saucer_surface

I just followed the recipe there. We’d like to display a saucer and see what we get. It may be upside down, which will be easy to fix, probably.

The ship initializes its surfaces like this:

        ship_scale = 4
        ship_size = Vector2(14, 8)*ship_scale
        self.ship_surface, self.ship_accelerating_surface = SurfaceMaker.ship_surfaces(ship_size)

I’ll do similarly in Saucer:

    def __init__(self, position=None):
        if position is not None: self.position = position
        self.score_list = [0, 0, 0]
        self.radius = 20
        saucer_scale = 4
        saucer_size = Vector2(10,6)*saucer_scale
        self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)

I’ll replicate draw from asteroid. It has some magic in it.

class Asteroid ...
    def draw(self, screen):
        top_left_corner = self.position - self.offset
        pygame.draw.circle(screen, "red", self.position, 3)
        screen.blit(self.surface, top_left_corner)

So for Saucer I’ll do the same thing:

class Saucer ...
    def draw(self, screen):
        top_left_corner = self.position - self.offset
        pygame.draw.circle(screen, "green", self.position, 3)
        screen.blit(self.saucer_surface, top_left_corner)

This demands offset, but I think we can fetch that from the surface. Or else it’ll be 4*(10,6)/2. Let’s put in that literal and see what we can draw.

    def __init__(self, position=None):
        if position is not None: self.position = position
        self.score_list = [0, 0, 0]
        self.radius = 20
        base_size = Vector2(10, 6)
        saucer_scale = 4
        self.offset = base_size*saucer_scale/2
        saucer_size = base_size*saucer_scale
        self.saucer_surface = SurfaceMaker.saucer_surface(saucer_size)

Now I’ll just create a Saucer somewhere and draw it.

OK, nearly good. I failed to fix up the copy-paste to refer to raw_saucer_points, and after that was done I get this:

upside down saucer

As I suspected, it’s upside down. The easiest fix will be to change the sign of all the y coordinates in the list. I’ll do that. It does the trick.

I am surprised to find a red dot in the middle of the Saucer, I thought I had changed that to green. Oh. I cleverly edited the code in the article, not the real code. Brilliant. Anyway I’ll remove that dot and the ones on the asteroids as well. I put them there to give me feedback on whether the display of the thing was centered around its position.

    def draw(self, screen):
        top_left_corner = self.position - self.offset
        screen.blit(self.saucer_surface, top_left_corner)

Let’s commit this, with the saucer just sitting there. It’s progress on the story and we can demo it to the customer. Commit: Saucer has surface and can draw.

Let’s reflect and plan.

Looking Both Ways

Looking back, that went just fine. We followed the recipe for the other surfaces and it worked just as one would like. Hardly any effort at all, with the usual risks from copying and pasting but still better than typing.

Looking forward … I think we’ll follow the ship’s pattern, where the Game owns the ship and has a ships collection which may or may not currently contain a ship. When it doesn’t, and a suitable time has elapsed, the game will initialize the Saucer and pop it into the collection, and voila! it will do its thing. I’ll do some of this as I plan. And we’ll want some tests.

We’ll do that in the next article. See you there!



  1. My name for the small sticky notes on my keyboard tray containing short notes about what I might work on. Probably more mechanism than most teams need, but I can’t think of anything simpler.