Let’s just inch forward by making the Ship turn … and inch forward. I think we basically know how to do this. (I do quite a bit of refactoring, an odd thing to do in a spike.)

We’ve got the ship drawing itself, with a transparency setting that causes it to draw just the lines, so that whatever background we may have will show through. We have it able to rotate to its angle, and it has an accelerating flag that causes it to flicker its tail, which we call the flare. I think it’s time to remove the ball from the screen, change the background color, and begin to make the ship fly.

Tentative Plan

  1. Remove the ball;
  2. Change background to, oh, dark blue;
  3. Rewire the keyboard to turn the ship and set its accelerating flag;
  4. Give ship a velocity, and increment velocity by acceleration as needed.

This may or may not be what we actually do. It’s just a plan.

Therefore:

Extensive research tells me that PyGame understands color names, lots of them, including “midnightblue”, which I think we’ll use. And let’s get rid of the ball.

    screen.fill("midnightblue")

    ship.angle += 1
    ship.draw(screen)

ship on dark blue sky

Perfect. Very advanced Asteroids, with a non-black sky. We rock.

The ship is just spinning in place, of course, with the angle being incremented every time through the loop. We’ll remove that and rewire this:

    keys = pygame.key.get_pressed()
    if keys[pygame.K_w]:
        player_pos.y -= 300 * dt
    if keys[pygame.K_s]:
        player_pos.y += 300 * dt
    if keys[pygame.K_a]:
        player_pos.x -= 300 * dt
    if keys[pygame.K_d]:
        player_pos.x += 300 * dt

And obviously I don’t need player_pos any more, better remove that. No, I’ll keep it but rename it screen_center because it happens to compute that … no, don’t hold on to your appendix when you don’t need it any more. I’ll remove it.

Foolish of me to remove it, because the ship initializes itself to player_pos. I’ll put it back and then inline it. I diff my changes and replace the line:

player_pos = pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2)
ship = Ship(player_pos)

Now refactor to inline. I suspect that’s Command+Opt+N. It is:

ship = Ship(pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2))

Now where were we. Oh, right, wiring up the controls:

    keys = pygame.key.get_pressed()
    if keys[pygame.K_f]:
        ship.angle -= 60*dt
    if keys[pygame.K_d]:
        ship.angle += 60*dt
    if keys[pygame.K_j]:
        ship.accelerating = True
    else:
        ship.accelerating = False

This will work, and it’s not really good. I think I’ve done it this way every time for the last N versions, and it can be better. First I do test it on the screen.

It does work, and the rotation is too slow. Let’s fix the real problem.

What is the “real” problem, you ask? This code knows way too much about how Ship works, futzing with its angle and acceleration. Let’s change these to method calls.

There may be some clever refactoring for this, but I don’t know what it is. I’ll just do it. First, provide the methods:

        self.angle -= 90*dt
        
    def turn_right(self, dt):
        self.angle += 90*dt
        
    def power_on(self, dt):
        self.accelerating = True
        
    def power_off(self, dt):
        self.accelerating = False

Now to call them:

    keys = pygame.key.get_pressed()
    if keys[pygame.K_f]:
        ship.turn_left(dt)
    if keys[pygame.K_d]:
        ship.turn_right(dt)
    if keys[pygame.K_j]:
        ship.power_on(dt)
    else:
        ship.power_off(dt)

That should do the job. Now when I run the program, I expect the d and f key to rotate the ship, and the j key to show the power flare when held down. And that’s what happens:

ship turns and shows flare

Now it really seems to me that because all these key-presses are related to the Ship, probably this if nest should be over there as well, but we’ll leave it for now. We have bigger things to deal with, namely making this thing move.

Moving the Ship

The ship, and doubtless our other objects as well, will have a velocity and a position. Each clock tick, we’ll want to update the ship’s position by adding a bit of its velocity to the position. And when it accelerates, we’ll add a bit to the velocity, in the direction the ship is pointing, not the direction it’s moving. Standard space ship stuff.

Now since I’m trying to learn Python and all its works and all its pomps, I really ought to write some tests for this. Having mentioned it gives me the energy to try it. Here goes.

    def test_ship_move(self):
        ship = Ship(vector2(50, 60))
        ship.velocity = vector2(10, 16)
        ship.move(0.5)
        self.assertEqual(ship.velocity, vector2(55, 68))

I’m assuming here that velocity is in coordinates per second, so in a half second (0.5), we’ll move a distance of half the velocity.

None of this will work, yet. Shall I run it to see what breaks? I’m reliably informed that Ship has no move. Who knew? Let’s implement some stuff:

    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0

    def move(self, dt):
        self.position += self.velocity*dt

I have high hopes for my test now. They are dashed, because this:

<Vector2(55, 68)> != <Vector2(10, 16)>

Oh. I’m checking ship.velocity not position. Clever. Here’s a pro tip: to determine if the right thing happens, check the right thing, not some other thing.

    def test_ship_move(self):
        ship = Ship(vector2(50, 60))
        ship.velocity = vector2(10, 16)
        ship.move(0.5)
        self.assertEqual(ship.position, vector2(55, 68))

This has better prospects.

Ran 3 tests in 0.002s

OK

OK indeed. I claim that ship.move obviously works. Let’s do acceleration. I’m not at all sure how much to accelerate. I could check one of the Kotlin versions, I suppose. It’s 120 over there. Let’s do a back of the envelope guess here.

The screen will be about 1000 points in size. We want about three seconds to cross the screen, so max velocity will be about 300. If we used 120 per second per second as acceleration, we’d get to max velocity in a little under three seconds. Probably 120 is good. Let’s write a test with that assumption.

Let’s also note that we don’t need to know the basic acceleration value for our test, we can pass in any acceleration we like.

    def test_ship_acceleration(self):
        ship = Ship(vector2(0, 0))
        ship.angle = 45
        ship.acceleration = 100
        ship.power_on(1.0)
        self.assertAlmostEqual(ship.velocity.x, 70.7106, 2)
        self.assertAlmostEqual(ship.velocity.y, 70.7106, 2)

Square root of 5000, according to Siri. The number seems familiar to me as well.

The test will fail on the assert, I think.

AssertionError: 0.0 != 70.7106 within 2 places (70.7106 difference)

Yes, perfect. Now:

    def power_on(self, dt):
        self.accelerating = True
        accel = vector2(dt*self.acceleration,0).rotate(self.angle)
        self.velocity += accel

Test runs green. Perfect. I think the ship will fly now, though it will fly off the screen. Well, it won’t, because we aren’t updating it in the loop.

    keys = pygame.key.get_pressed()
    if keys[pygame.K_f]:
        ship.turn_left(dt)
    if keys[pygame.K_d]:
        ship.turn_right(dt)
    if keys[pygame.K_j]:
        ship.power_on(dt)
    else:
        ship.power_off(dt)
    ship.move(dt)

We see the Feature Envy clearly here. We’ll deal with that shortly. (Well, later. Not today, but I think we should have the controls closer to the ship.)

A test flight shows me that the x motion is correct and the y motion is incorrect. When I point the ship left it goes left. When I point it up, it goes down. So I think the angle needs to be reversed:

    def power_on(self, dt):
        self.accelerating = True
        accel = vector2(dt*self.acceleration,0).rotate(-self.angle)
        self.velocity += accel

ship flying

Now the screen looks right, but the test will probably fail. This is the old y axis goes down problem, basically.

AssertionError: -70.71067811865477 != 70.7106 within 2 places (141.42127811865475 difference)

Right. Fix the test:

    def test_ship_acceleration(self):
        ship = Ship(vector2(0, 0))
        ship.angle = 45
        ship.acceleration = 100
        ship.power_on(1.0)
        self.assertAlmostEqual(ship.velocity.x, 70.7106, 2, "x value")
        self.assertAlmostEqual(ship.velocity.y, -70.7106, 2, "y value")

Green as your Captain Midnight Secret Decoder Ring finger. Let’s commit: Ship can accelerate and move according to velocity. No screen wrap.

Now let’s review the code. I think we’ve done enough for the morning. Here’s ship as it now stands, let’s see what we notice.

class Ship:
    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.raw_ship = [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)]
        self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        self.ship_surface = pygame.Surface((60, 36))
        self.ship_surface.set_colorkey((0, 0, 0))
        self.ship_accelerating_surface = pygame.Surface((60, 36))
        self.ship_accelerating_surface.set_colorkey((0, 0, 0))
        self.paint()

    def adjust(self, point):
        return point*4 + vector2(28, 16)

    def draw(self, screen):
        ship_source = self.select_ship_source()
        copy = pygame.transform.rotate(ship_source.copy(), self.angle)
        half = pygame.Vector2(copy.get_size())/2
        screen.blit(copy, self.position - half)

    def move(self, dt):
        self.position += self.velocity*dt

    def power_on(self, dt):
        self.accelerating = True
        accel = vector2(dt*self.acceleration,0).rotate(-self.angle)
        self.velocity += accel

    def power_off(self, dt):
        self.accelerating = False

    def paint(self):
        ship_points = list(map(self.adjust, self.raw_ship))
        flare_points = list(map(self.adjust, self.raw_flare))
        pygame.draw.lines(self.ship_surface, "white", False, ship_points, 3)
        pygame.draw.lines(self.ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(self.ship_accelerating_surface, "white", False, flare_points, 3)

    def select_ship_source(self):
        if self.accelerating and random.random() >= 0.66:
            return self.ship_accelerating_surface
        else:
            return self.ship_surface

    def turn_left(self, dt):
        self.angle -= 90*dt

    def turn_right(self, dt):
        self.angle += 90*dt

We see quite a few magic numbers here, but most of them relate to mapping the ship’s points to the screen image. There’s the 90 for turning, the 3 for line thickness. And there’s a lot of stuff going on in the init, including preparing the graphics. Let’s refactor that a bit.

Python doesn’t like it when we init a member variable outside of init. That’s good advice and I think we’ll follow it. Let’s see how we can improve this to wind up with the painting all extracted:

    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.raw_ship = [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)]
        self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        self.ship_surface = pygame.Surface((60, 36))
        self.ship_surface.set_colorkey((0, 0, 0))
        self.ship_accelerating_surface = pygame.Surface((60, 36))
        self.ship_accelerating_surface.set_colorkey((0, 0, 0))
        self.paint()

Let’s move toward a function returning both surfaces, so that we can do this:

    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.ship_surface, self.ship_accelerating_surface = self.prepare_surfaces()
        self.raw_ship = [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)]
        self.raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        self.paint()

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        return ship_surface, ship_accelerating_surface

This should continue to work. I just moved the creation and keying lines and removed the self. Now let’s grab the other four lines and pack them in there like this:

    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.ship_surface, self.ship_accelerating_surface = self.prepare_surfaces()

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_ship = [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 = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        self.paint(raw_ship, raw_flare)
        return ship_surface, ship_accelerating_surface

    def paint(self, raw_ship, raw_flare):
        ship_points = list(map(self.adjust, raw_ship))
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(self.ship_surface, "white", False, ship_points, 3)
        pygame.draw.lines(self.ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(self.ship_accelerating_surface, "white", False, flare_points, 3)

That won’t quite work. We need to pass in the surfaces, since they aren’t members yet.

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_ship = [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 = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        self.paint(raw_ship, raw_flare, ship_surface, ship_accelerating_surface)
        return ship_surface, ship_accelerating_surface

    def paint(self, raw_ship, raw_flare, ship_surface, ship_accelerating_surface):
        ship_points = list(map(self.adjust, raw_ship))
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

That’s OK but I don’t love it. Let’s inline paint.

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_ship = [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 = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        ship_points = list(map(self.adjust, raw_ship))
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)
        return ship_surface, ship_accelerating_surface

Now that we have it all together, let’s reorder some stuff. I’ve spaced things out to show one somewhat sensible grouping:

    def prepare_surfaces(self):
        raw_ship = [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)]
        ship_points = list(map(self.adjust, raw_ship))
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)
        
        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)
        
        return ship_surface, ship_accelerating_surface

I am tempted to inline a lot of this, extract methods, and then extract it back out. Let’s try that on ship.

No, that gets so ugly that I won’t even show you what it looks like. Let’s try a different ordering of the code.

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        raw_ship = [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)]
        ship_points = list(map(self.adjust, raw_ship))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

Right. Now a couple of extract methods. No, first, inline the points:

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        raw_ship = [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)]
        pygame.draw.lines(ship_surface, "white", False, list(map(self.adjust, raw_ship)), 3)

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_accelerating_surface, "white", False, list(map(self.adjust, raw_ship)), 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

No, not quite. I don’t like how that’s going. We need both sets of points for the accelerating version. But one possibility would be to change the draw to draw the ship and flare separately. Let’s press on with the extract and see where it leads. We can always roll back.

Meh. It does this:

	def prepare_surfaces(self):
        raw_ship, ship_surface = self.prepare_ship()

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_accelerating_surface, "white", False, list(map(self.adjust, raw_ship)), 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

    def prepare_ship(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        raw_ship = [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)]
        pygame.draw.lines(ship_surface, "white", False, list(map(self.adjust, raw_ship)), 3)
        return raw_ship, ship_surface

I don’t really want the raw ship any more. Revert that. Back to this:

    def prepare_surfaces(self):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        raw_ship = [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)]
        ship_points = list(map(self.adjust, raw_ship))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

Before I do any more, I want to get this committed. We’re green. Commit: beginning refactoring of surface preparation. Now I have a nice roll-back point.

How about if we create the points first, then do the rest?

    def prepare_surfaces(self):
        raw_ship = [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)]
        ship_points = list(map(self.adjust, raw_ship))
        
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

Now extract creation of the points:

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()

        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)

        ship_accelerating_surface = pygame.Surface((60, 36))
        ship_accelerating_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_accelerating_surface, "white", False, ship_points, 3)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

    def get_flare_points(self):
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        flare_points = list(map(self.adjust, raw_flare))
        return flare_points

    def get_ship_points(self):
        raw_ship = [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)]
        ship_points = list(map(self.adjust, raw_ship))
        return ship_points

Ah, that’s starting to look like something I can live with. Nth time’s the charm. Let’s improve those methods a bit. Two quick applications of Inline and we get:

    def get_flare_points(self):
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        return list(map(self.adjust, raw_flare))

    def get_ship_points(self):
        raw_ship = [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)]
        return list(map(self.adjust, raw_ship))

Better. I think I’ll leave the explaining raw variables.

Now back at home base, when I extract make_ship_surface, PyCharm offers to use it in what follows. Interesting:

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()

        ship_surface = self.make_ship_surface(ship_points)

        ship_accelerating_surface = self.make_ship_surface(ship_points)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)

        return ship_surface, ship_accelerating_surface

    def make_ship_surface(self, ship_points):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)
        return ship_surface

It noticed the duplication of the draw.lines that I had for creating the ship with flare, and used the new function instead. Nice. Now extract that:

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()
        ship_surface = self.make_ship_surface(ship_points)
        ship_accelerating_surface = self.make_accelerating_surface(flare_points, ship_points)
        return ship_surface, ship_accelerating_surface

    def make_accelerating_surface(self, flare_points, ship_points):
        ship_accelerating_surface = self.make_ship_surface(ship_points)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)
        return ship_accelerating_surface

Let’s do a bit of inlining here to see if we like it. Shall we test and commit this first? Yes, we shall. Commit: refactoring surface building. Now the inlining:

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()
        return (self.make_ship_surface(ship_points)), (self.make_accelerating_surface(flare_points, ship_points))

I think I actually like that. One might object to the return of two things from one call:

    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.ship_surface, self.ship_accelerating_surface 
        	= self.prepare_surfaces()

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()
        return (self.make_ship_surface(ship_points)), (self.make_accelerating_surface(flare_points, ship_points))

I have mixed feelings about it myself. We could return them separately but they are really kind of a package. Hmm … what if they were in an object of their own and the ship just told that object to draw. Maybe a ShipView or something.

We’ll leave that for another day. Let’s sum up.

Summary

After making the ship fly, we took a look at our code and mushed it around in a few different ways, trying to make it nicer. Almost all those changes were machine refactorings, which makes them very safe, even in a language like Python. We’d have to be careful with renames and such but extract and inline are pretty safe.

This was a big investment in tidy code if this is really just a spike, but I’m here to learn how to do this stuff in Python and PyGame, so the investment is worth it even if we do throw this away. And I’m starting to think that we might not.

When we look at Ship now, we see it has a few different aspects:

  1. It can move, turn, and accelerate;
  2. It can draw itself;
  3. It creates some complicated surfaces.

The general rules of cohesion suggest that there might be as many as three different objects wishing to be born here, one that makes surfaces, one that adjusts geometry, and one that draws. We may explore that later.

Right now, it feels a bit too deep in refining Ship when we don’t have any Asteroids or Missiles. But it might not be too soon if we were going to ship this game, or even if we were thinking, well, Ship is done, let’s do Asteroid. Ship isn’t really done, it’s still a bit messy.

And we’d really better do screen wrapping before the ship flies off and shoots down my chai.

Take a final look and decide for yourself, and I’ll hope to see you next time!


class Ship:
    def __init__(self, position):
        self.position = position
        self.velocity = vector2(0, 0)
        self.angle = 0
        self.acceleration = 120
        self.accelerating = False
        self.ship_surface, self.ship_accelerating_surface = self.prepare_surfaces()

    def prepare_surfaces(self):
        ship_points = self.get_ship_points()
        flare_points = self.get_flare_points()
        return (self.make_ship_surface(ship_points)), (self.make_accelerating_surface(flare_points, ship_points))

    def make_accelerating_surface(self, flare_points, ship_points):
        ship_accelerating_surface = self.make_ship_surface(ship_points)
        pygame.draw.lines(ship_accelerating_surface, "white", False, flare_points, 3)
        return ship_accelerating_surface

    def make_ship_surface(self, ship_points):
        ship_surface = pygame.Surface((60, 36))
        ship_surface.set_colorkey((0, 0, 0))
        pygame.draw.lines(ship_surface, "white", False, ship_points, 3)
        return ship_surface

    def get_flare_points(self):
        raw_flare = [vector2(-3.0, -2.0), vector2(-7.0, 0.0), vector2(-3.0, 2.0)]
        return list(map(self.adjust, raw_flare))

    def get_ship_points(self):
        raw_ship = [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)]
        return list(map(self.adjust, raw_ship))

    def adjust(self, point):
        return point*4 + vector2(28, 16)

    def draw(self, screen):
        ship_source = self.select_ship_source()
        copy = pygame.transform.rotate(ship_source.copy(), self.angle)
        half = pygame.Vector2(copy.get_size())/2
        screen.blit(copy, self.position - half)

    def move(self, dt):
        self.position += self.velocity*dt

    def power_on(self, dt):
        self.accelerating = True
        accel = vector2(dt*self.acceleration,0).rotate(-self.angle)
        self.velocity += accel

    def power_off(self, dt):
        self.accelerating = False

    def select_ship_source(self):
        if self.accelerating and random.random() >= 0.66:
            return self.ship_accelerating_surface
        else:
            return self.ship_surface

    def turn_left(self, dt):
        self.angle -= 90*dt

    def turn_right(self, dt):
        self.angle += 90*dt