Python Asteroids on GitHub

We want to get rid of the GFragment class. To do that, we have to get a little head.

The Fragment class draws lines because each element of its fragments collection represents a line by two points:

    def draw(self, screen):
        self.draw_lines(screen, self.position, self.theta, self.fragments)

    def draw_lines(self, screen, position, theta, pairs):
        for pair in pairs:
            self.draw_one_line(screen, position, theta, pair)

    def draw_one_line(self, screen, position, theta, pair):
        start = pair[0].rotate(theta) + position
        end = pair[1].rotate(theta) + position
        pygame.draw.line(screen, "white", start, end, 3)

But our astronaut fragment is a little guy and he has a head:

    def draw(self, screen):
        super().draw(screen)
        self.draw_head(screen)

    def draw_head(self, screen):
        head_off = Vector2(0, 16 + 8).rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, 8, 2)

His head is drawn, in that classical drawing style, as a small circle.

astronaut showing more or less round head

Now if our Fragment.draw knew how to draw a circle, we could get rid of this whole class. That would be outstanding.

We want to turn our list of pairs of coordinates into a list of commands. Let’s do this in the simplest way I can think of, just putting a string or something at the front of each pair.

Let’s look at the hard example:

    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        body_bottom = Vector2(0, 2)
        body = [Vector2(0, 16), body_bottom]
        left_leg = [Vector2(-5, -16), body_bottom]
        right_leg = [Vector2(5, -16), body_bottom]
        arm = [Vector2(-9, 10), Vector2(9, 10)]
        return GFragment(position, angle, speed_mul, [body, arm, left_leg, right_leg])

That’s not hard at all, is it. I’m tempted to just put “line” in there, but let’s do it this way:

    @classmethod
    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        body_bottom = Vector2(0, 2)
        body = [line, Vector2(0, 16), body_bottom]
        left_leg = [line, Vector2(-5, -16), body_bottom]
        right_leg = [line, Vector2(5, -16), body_bottom]
        arm = [line, Vector2(-9, 10), Vector2(9, 10)]
        return GFragment(position, angle, speed_mul, [body, arm, left_leg, right_leg])

I’ll do the other two as well:

    @classmethod
    def simple_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        half_length = random.uniform(6, 10)
        begin = Vector2(-half_length, 0)
        end = Vector2(half_length, 0)
        return cls(position, angle, speed_mul, [[line, begin, end]])

    @classmethod
    def v_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        side_1 = [line, Vector2(-7, 5), Vector2(7, 0)]
        side_2 = [line, Vector2(7, 0), Vector2(-7, -5)]
        return cls(position, angle, speed_mul, [side_1, side_2])

Now this isn’t going to work until I change draw. We’ll change this:

    def draw(self, screen):
        self.draw_lines(screen, self.position, self.theta, self.fragments)

    def draw_lines(self, screen, position, theta, pairs):
        for pair in pairs:
            self.draw_one_line(screen, position, theta, pair)

Into this:

    def draw(self, screen):
        self.draw_commands(screen, self.position, self.theta, self.fragments)

    def draw_commands(self, screen, position, theta, commands):
        for command in commands:
            self.draw_one_line(screen, position, theta, command[1:])

The command[1:] returns everything in the command after the zeroth element, that is, the two parameters to line.

We are green. Commit: moving toward Fragment commands, command element expected and ignored.

Now let’s actually use the command part.

    def draw_commands(self, screen, position, theta, commands):
        for command in commands:
            operation = command[0]
            if operation == "line":
                self.draw_one_line(screen, position, theta, command[1:])
            else:
                pass

Still green. Commit: Fragment uses opcode ‘line’.

Now, it seems to me, we “just” need a new opcode, “circle”. No real point giving it any parameters other than the position, we only have exactly one use of it.

I’ll prepare the code for it. Once I see what the code looks like, I decide to make the command “head”:

    def draw_commands(self, screen, position, theta, commands):
        for command in commands:
            operation = command[0]
            if operation == "line":
                self.draw_one_line(screen, position, theta, command[1:])
            elif operation == "head":
                self.draw_head(screen)
            else:
                pass

    def draw_head(self, screen):
        head_off = Vector2(0, 16 + 8).rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, 8, 2)

As written, the head knows its position and radius. We’ll fix that, I think.

Now I should be able to add a head command to the astronaut_fragment and use the base class.

    @classmethod
    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        head = ["head"]
        body_bottom = Vector2(0, 2)
        body = [line, Vector2(0, 16), body_bottom]
        left_leg = [line, Vector2(-5, -16), body_bottom]
        right_leg = [line, Vector2(5, -16), body_bottom]
        arm = [line, Vector2(-9, 10), Vector2(9, 10)]
        return cls(position, angle, speed_mul, [head, body, arm, left_leg, right_leg])

Note that my head command has no parameters. The tests are green (no surprise there) and the game draws the explosion correctly.

Commit: astronaut_fragment uses Fragment. GFragment no longer needed.

Remove GFragment class. Commit: remove GFragment class.

Let’s see where we stand, it might be best to provide the head parameters in the command:

    @classmethod
    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        head = ["head"]
        body_bottom = Vector2(0, 2)
        body = [line, Vector2(0, 16), body_bottom]
        left_leg = [line, Vector2(-5, -16), body_bottom]
        right_leg = [line, Vector2(5, -16), body_bottom]
        arm = [line, Vector2(-9, 10), Vector2(9, 10)]
        return cls(position, angle, speed_mul, [head, body, arm, left_leg, right_leg])

    def draw_commands(self, screen, position, theta, commands):
        for command in commands:
            operation = command[0]
            if operation == "line":
                self.draw_one_line(screen, position, theta, command[1:])
            elif operation == "head":
                self.draw_head(screen)
            else:
                pass

    def draw_head(self, screen):
        head_off = Vector2(0, 16 + 8).rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, 8, 2)

Sure, let’s do that. We’ll add the offset, radius, and width to the “head” command:

    def astronaut_fragment(cls, position, angle=None, speed_mul=None):
        line = "line"
        head = ["head", Vector2(0, 24), 8, 2]
        body_bottom = Vector2(0, 2)
        body = [line, Vector2(0, 16), body_bottom]
        left_leg = [line, Vector2(-5, -16), body_bottom]
        right_leg = [line, Vector2(5, -16), body_bottom]
        arm = [line, Vector2(-9, 10), Vector2(9, 10)]
        return cls(position, angle, speed_mul, [head, body, arm, left_leg, right_leg])

We can commit this: added offset, radius, line width to head command. not interpreted yet.

I’m trying to develop the habit of committing every time we’re good. I’m not there yet.

Now let’s pass the parameters:

    def draw_commands(self, screen, position, theta, commands):
        for command in commands:
            operation = command[0]
            if operation == "line":
                self.draw_one_line(screen, position, theta, command[1:])
            elif operation == "head":
                self.draw_head(screen, command[1:])
            else:
                pass

    def draw_head(self, screen, parameters):
        head_off = Vector2(0, 16 + 8).rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, 8, 2)

We’re not using them but we’re good to go anyway: commit: head parameters passed to draw_head and ignored.

Now let’s use them:

    def draw_head(self, screen, parameters):
        position, radius, width = parameters
        head_off = position.rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, radius, width)

Works, of course. Commit: head command now fully implemented in Fragment.

Reflection

Over the course of 34 commits and about five hours, we’ve gone from three classes of ad-hoc drawing code down to one class that accepts a tiny command language to draw our fragments.

We haven’t saved many lines of code, but we’ve removed two classes and some implementation inheritance from the system. The class still looks a bit complex to me, in three regards.

First, the __init__ is quite complex, because of all the randomness we want, plus some defaults that made testing easier:

class Fragment:
    def __init__(self, position, angle=None, speed_mul=None, fragments=None):
        angle = angle if angle is not None else random.randrange(360)
        self.position = position
        speed_mul = speed_mul if speed_mul is not None else random.uniform(0.25, 0.5)
        self.velocity = speed_mul*Vector2(u.FRAGMENT_SPEED, 0).rotate(angle)
        self.theta = random.randrange(0, 360)
        self.delta_theta = random.uniform(180, 360)*random.choice((1, -1))
        self.timer = Timer(u.FRAGMENT_LIFETIME, self.timeout)
        self.fragments = fragments

We’d do better without those defaulted parameters, but my testing really wants them. I think better names would help here. We might look at that another time.

Second, the drawing code is pretty straightforward, but it’s got a few methods:

    def draw(self, screen):
        for command in self.fragments:
            operation = command[0]
            if operation == "line":
                self.draw_one_line(screen, self.position, self.theta, command[1:])
            elif operation == "head":
                self.draw_head(screen, command[1:])
            else:
                pass

    def draw_head(self, screen, parameters):
        position, radius, width = parameters
        head_off = position.rotate(self.theta)
        pygame.draw.circle(screen, "white", self.position + head_off, radius, width)

    def draw_one_line(self, screen, position, theta, pair):
        start = pair[0].rotate(theta) + position
        end = pair[1].rotate(theta) + position
        pygame.draw.line(screen, "white", start, end, 3)

We could simply those a bit by recognizing that we have position and theta as member variables and not pass them. Might look at that later as well.

Finally, the Fragment has all the usual bits for ticking, timing, and moving:

    def move(self, delta_time):
        position = self.position + self.velocity * delta_time
        position.x = position.x % u.SCREEN_SIZE
        position.y = position.y % u.SCREEN_SIZE
        self.position = position
        self.theta += self.delta_theta*delta_time

    def tick(self, delta_time, fragments, _fleets):
        self.timer.tick(delta_time, fragments)
        self.move(delta_time)

    def timeout(self, fragments):
        fragments.remove(self)

I think that move code probably exists in at least one more place if not four. In fact, four appear to be identical to that one. Looks like a method on a ScreenPosition object … if we had one. Instead we’re using the nearly native Vector2 type. Should always wrap your native types, innit?

So … should we do something about this class? It does have at least two responsibilities, acting like a Flyer, and drawing itself, but all the Flyers draw themselves.

This class is of a similar size to Asteroid, Missile, Ship, and Saucer, and it’s not the largest of them. I think we’ll accept it as it, although of course we’ll surely look at it again.

Summary

Over a period of five articles, we have created our “over the top” explosion, and refactored it first into three independent classes, and then down to one class with three factory methods and a tiny little language to define how to draw things.

Over the course of the last few articles, I got into the habit of very tiny steps and committing every time we were shippable, bringing my average time between commits down to the square root of not very much at all, with 58 commits over those five articles. I feel good about that.

The other thing is that I started out with a few decent tests for the base object, and those were solid enough to let me refactor with impunity. I really only tried the game once in a while because I couldn’t believe how well it was going.

We have things we might like to do. Someone mentioned having the fragments fade out over time. And with all those parameter in Fragment.__init__, I have to wonder if there is an object hiding in there somewhere.

What we see, again and again, is that what seems like perfectly decent code can be improved, and if you believe, as I do, that better code is easier to work with, it at least suggests that we do well to take a bit of time to improve the code.

Of course, in your real world, you don’t have the luxury that I have of trying six ways from Sunday, but because better code is easier to work with, applying similar ideas to active code might well pay off for you. I know that’s what I’d do in a production system. I’d clean up the code area before starting on a feature, and again after. And, once in a while, if I had a little free time, I’d clean something up then.

You, of course, should do you.

See you next time!