Python Asteroids on GitHub

This morning PyCharm and my code rubbed my nose in the notion that a “static method” signal may mean that the method in question really belongs on some other class. Let’s explore that idea.

I’ll just search for a few examples and see what we think.

class Fragment(Flyer):
    @staticmethod
    def are_we_colliding(_position, _radius):
        return False

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

are_we_colliding is a required method for Flyers, and generally looks at the receiver’s position and radius. This is a special case because Fragments don’t collide. I suspect, however, that there are other Flyers that also do not collide. We should think about that.

draw_one_line could be hinting that pair is a tiny object that should be able to draw itself. Let’s look in more detail at Fragment.

    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)

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

Let’s look more deeply. I think a Fragment is a command of some kind, each one dumped separately into the mix to draw itself:

    @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])

    @classmethod
    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])

    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.fragments = fragments

So we have a command keyword, and a collection of fragments, and we draw them. Instead of those triples, we might want a new tiny class that can draw things. But the two types seem to be a bit different. The “line” type have two points, and the “head” type take a point and a radius and width:

    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)

Let’s just create a tiny class:

class LineFragment:
    def __init__(self, pos1, pos2):
        self._pos1 = pos1
        self._pos2 = pos2

    def draw(self, screen, theta):
        p1 = self._pos1.rotate(theta)
        p2 = self._pos2.rotate(theta)
        pygame.draw.line(screen, "white", p1, p2)

Now let’s build those instead of our line lists. I expect to run into trouble.

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

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

I think I have to do the head right now to have any chance of this going well. Easy enough:

class CircleFragment:
    def __init__(self, position, radius, width):
        self._pos = position
        self._radius = radius
        self._width = width

    def draw(self, screen, position, theta):
        head_off = position + self._pos.rotate(theta)
        pygame.draw.circle(screen, "white", position + head_off, self._radius, self._width)

That was especially worth doing, because it helped me see that I need the position at which to draw the thing, not just the angle. Changed LineFragment equivalently. Now …

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

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

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

Now to change draw. I am not sure about this, it was a lot to do all at once.

    def draw(self, screen):
        for command in self.fragments:
            command.draw(screen, self.position, self.theta)

If I’ve done this right … well we just have to run it and see what happens.

It doesn’t look quite right. The lines are too narrow. I forgot the line width, which we can build right in to the draw:

class LineFragment:
    def __init__(self, pos1, pos2):
        self._pos1 = pos1
        self._pos2 = pos2

    def draw(self, screen, position, theta):
        p1 = position + self._pos1.rotate(theta)
        p2 = position + self._pos2.rotate(theta)
        pygame.draw.line(screen, "white", p1, p2, 3)

That fixes the single lines and the vee. The head isn’t drawing at all. Let’s take a closer look at that.

Ah. I over-compensated:

    def draw(self, screen, position, theta):
        head_off = position + self._pos.rotate(theta)
        pygame.draw.circle(screen, "white", position + head_off, self._radius, self._width)

Shouldn’t be applying position twice. There’s probably a head floating around somewhere.

    def draw(self, screen, position, theta):
        head_off = self._pos.rotate(theta)
        pygame.draw.circle(screen, "white", position + head_off, self._radius, self._width)

That should do it. And it does.

explosion looks right

So that went fairly well. Let’s commit: Convert Fragment to use LineCommand and CircleCommand tiny drawing objects.

Now, let’s look around and see what we have. Since the Fragment draw looks like this:

    def draw(self, screen):
        for command in self.fragments:
            command.draw(screen, self.position, self.theta)

We should rename our Line and Circle classes to be commands:

class LineCommand:
    def __init__(self, pos1, pos2):
        self._pos1 = pos1
        self._pos2 = pos2

    def draw(self, screen, position, theta):
        p1 = position + self._pos1.rotate(theta)
        p2 = position + self._pos2.rotate(theta)
        pygame.draw.line(screen, "white", p1, p2, 3)


class CircleCommand:
    def __init__(self, position, radius, width):
        self._pos = position
        self._radius = radius
        self._width = width

    def draw(self, screen, position, theta):
        head_off = self._pos.rotate(theta)
        pygame.draw.circle(screen, "white", position + head_off, self._radius, self._width)

Those two classes do have the same protocol for drawing. We could imagine providing an interface / superclass but duck typing.

The rest is basically nothing but building up the various fragment commands. Each Fragment consists of a single line, a pair of lines (vee) or a whole bunch of lines and a circle (astronaut):

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

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

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

    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.fragments = fragments

Each fragment, be it a line or vee or astronaut, gets its own random velocity, angle (theta) and speed of rotation (delta_theta), and they move accordingly:

    def update(self, delta_time, _fleets):
        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

It’s a lot of stuff, but it’s a nice little effect. And now it is notably simpler.

Wondering

Could it be simpler still? Conceivably, since we have an Explosion object, which creates Fragment objects that consist of a collection of Line- or CircleCommand objects. There might be about a half an object more in there than we need.

We’ll leave that for another day, possibly never. Is never good for you?

What have we actually accomplished right now? We noticed that this method was static and odd:

    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)

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

And now it’s just that tiny loop. The draw methods become a simple draw on each of the Command objects. The command creation methods are essentially as they were, just creating instance of LineCommand or CicleCommand instead of simple arrays with a string opcode.

Just a tiny bit better … but unquestionably better. Once again, a static method is an indication that improvement is possible.

Summary

As with many of the changes we’re making to this program, the payoff is low, because the program is small and essentially complete. We are using this little example as a source for observing the opportunities for better code, or different code, and for observing how such improvements can be done.

I don’t know whether there was an even better way to do this one. I am rather sure that when I initially thought of a little array with a string opcode as a command, making it a small object right then would have been “better”. A list with a subtle structure or meaning? That’s just begging to be an object. Don’t make your code beg: it’s cruel.

However, in the heat of the moment, we may not always see the signal, sniff the scent. We often have our minds on other things. We do the best we can in the moment … and in later moments we improve what we have. That keeps the code alive.

Here’s an interesting question … could the Ship, Saucer, or Asteroids be made up of these commands? If they could be, should they be?

I don’t think we’ll go there … but it’s an interesting idea.

See you next time!