Ships Remaining Indicators. After that, who knows?

We’ve got the game giving you a fixed number of ships, four at present. When your last ship is destroyed, GAME OVER. We need to display a little row of the remaining ships, up under the score line.

How do we draw a ship? It looks like this:

function Ship:draw()
   local sx = 10
   local sy = 6
   pushStyle()
   pushMatrix()
   translate(self.pos.x, self.pos.y)
   rotate(math.deg(self.radians))
   strokeWidth(1)
   stroke(255)
   scale(2)
   line(-3,-2, -3,2)
   line(-3,2, -5,4)
   line(-5,4, 7,0)
   line(7,0, -5,-4)
   line(-5,-4,-3,-2)
   accel = (accel+1)%3
   if U.button.go and accel == 0 then
       strokeWidth(1.5)
       line(-3,-2, -7,0)
       line(-7,0, -3,2)
   end
   popMatrix()
   popStyle()
end

We need now to draw ships at an arbitrary position and rotation. We could create ships and draw them there, but then we’d have to protect them from being crashed, ahnd they’d mess up the ship counter, and so on. Let’s not have ships, let’s just draw some.

We refactor Ship:draw():

function Ship:draw()
    self:drawAt(self.pos, self.radians)
end

function Ship:drawAt(pos,radians)
   local sx = 10
   local sy = 6
   pushStyle()
   pushMatrix()
   translate(pos.x, pos.y)
   rotate(math.deg(radians))
   strokeWidth(1)
   stroke(255)
   scale(2)
   line(-3,-2, -3,2)
   line(-3,2, -5,4)
   line(-5,4, 7,0)
   line(7,0, -5,-4)
   line(-5,-4,-3,-2)
   accel = (accel+1)%3
   if U.button.go and accel == 0 then
       strokeWidth(1.5)
       line(-3,-2, -7,0)
       line(-7,0, -3,2)
   end
   popMatrix()
   popStyle()
end

A quick check to be sure the real ship still works yields no surprises: it does.

Now in Score, we can draw the ships. I’ll have to guess where to put them and then adjust by eye.

function Score:draw()
    local s= "000000"..tostring(self.totalScore)
    s = string.sub(s,-5)
    pushStyle()
    fontSize(100)
    text(s, 200, HEIGHT-60)
    if self.gameIsOver then
        text("GAME OVER", WIDTH/2, HEIGHT/2)
    end
    popStyle()
end

A bit of fiddling and it works nicely:

function Score:draw()
    local s= "000000"..tostring(self.totalScore)
    s = string.sub(s,-5)
    pushStyle()
    fontSize(100)
    text(s, 200, HEIGHT-60)
    for i = 1,self.shipCount do
        Ship:drawAt(vec2(330-i*20, HEIGHT-120), math.pi/2)
    end
    if self.gameIsOver then
        text("GAME OVER", WIDTH/2, HEIGHT/2)
    end
    popStyle()
end

ship count

I love it when a plan comes together.

I should probably quit while I’m ahead but maybe I’m really hot today. What else might be good to do?

Zigs and Zags

I know. Our ship just flies straight across the screen. Let’s make it zig and zag a bit, like the original. I’ve thought about this a little bit and I think the ship has a constant X direction, and one possibility is simply to give it a bit of Y motion, plus or minus, as well. That would make it speed up when it’s moving at an angle, and that might be bad. I’m inclined to try it, and find out.

I think it’s about 1300 pixels across the screen, and maybe he should zig or zag about three times. So one way might be to roll the dice on every move cycle, and adjust the angle if they come up snake eyes. Another perhaps less costly approach might be to set up a random tween to adjust his angle.

I confess that I’m enamored of the tween approach because it’s more declarative, probably more efficient, better factored, and cool. The cool thing is a bit of a problem, because code should never be cool, but it’s not very invasive, so we’ll try it.

Remind me to stop the tween when the ship dies.

It seems to take the saucer about 5 seconds to fly across, and it presently lives for 7 seconds if it’s lucky. Let’s initialize the tween to, oh, 2 to 3 seconds:

function Saucer:init(optionalPos)
    function die()
        if self == Instance then
            self:dieQuietly()
        end
    end
    Instance = self
    U:addObject(self)
    self.shotSpeed = 5
    self.pos = optionalPos or vec2(0, math.random(HEIGHT))
    self.step = vec2(2,0)
    self.fireTime = U.currentTime + 1 -- one second from now
    if math.random(2) == 1 then self.step = -self.step end
    self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
    self:randomTurn()
    tween.delay(7, die)
end

So I’ve posited randomTurn(), and my plan is to have it save the tween in a member variable, so we can stop it as needed. Now the minor detail of writing it, and the function it calls:

Meh. That gets messy. Since we want to turn more than once, the function that does the turn has to set up another tween that calls the function its in and even if I can figure it out, it will be hard to understand. Best to use a simpler approach.

function Saucer:init(optionalPos)
    function die()
        if self == Instance then
            self:dieQuietly()
        end
    end
    Instance = self
    U:addObject(self)
    self.shotSpeed = 5
    self.pos = optionalPos or vec2(0, math.random(HEIGHT))
    self.step = vec2(2,0)
    self.fireTime = U.currentTime + 1 -- one second from now
    if math.random(2) == 1 then self.step = -self.step end
    self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
    self:setRandomTurnTime()
    tween.delay(7, die)
end

function Saucer:setRandomTurnTime()
    self.turnTime = ElapsedTime + 2 + math.random()
end

function Saucer:randomTurn()
    local y = math.random(-1,1)*2
    self.step = vec2(self.step.x, y)
    self:setRandomTurnTime()
end

function Saucer:move()
    if self.turnTime < ElapsedTime then
        self:randomTurn()
    end
    U:moveObject(self)
    if U.currentTime >= self.fireTime then
        U:playStereo(U.sounds.saucerFire, self)
        self.fireTime = U.currentTime + 0.5
        SaucerMissile:fromSaucer(self)
    end
end

This works. He doesn’t turn often enough. We can shorten the time, but it also occurs to me that sometimes he’ll continue the same path, one out of three times, because he rolls the same direction again. I think I’ll let that be and change the time to 0.5-1.5 seconds and see how I like it:

function Saucer:setRandomTurnTime()
    self.turnTime = ElapsedTime + 0.5 + math.random()
end

That’s nearly good, but I think it has made him too fast. Let’s normalize the motion vector to his standard speed of 2.

function Saucer:randomTurn()
    local x = self.step.x > 0 and 1 or -1
    local y = math.random(-1,1)
    self.step = vec2(x, y):normalize()*2
    self:setRandomTurnTime()
end

That may seem tricky. This bit, especially:

 self.step.x > 0 and 1 or -1

That returns 1 if x is positive, otherwise -1. The random y gets -1, 0, or 1, randomly. We make a vector of those values, normalize it (to length one) then double to get our standard speed of two.

Is that too weird? I think not, and it looks good on the screen.

Hm, I forgot to commit the ship count. Better commit to trunk now: display ship count, random saucer turns.

Small Saucer?

Shall we go for the threefer and do the small saucer? Let’s do.

I don’t know quite what the real game did about small saucers, but for us, let’s say that after a score of 3,000 you get small saucers instead of large.

Now the easy way to do that would be to put the score-testing logic in Saucer, but that’s what we call “feature envy”. We’re wishing that the score keeper would tell us what size to make. If we do it that way, the decision about special score valeus is kept inside Score.

So this is Saucer:init():

function Saucer:init(optionalPos)
    function die()
        if self == Instance then
            self:dieQuietly()
        end
    end
    Instance = self
    U:addObject(self)
    self.shotSpeed = 5
    self.pos = optionalPos or vec2(0, math.random(HEIGHT))
    self.step = vec2(2,0)
    self.fireTime = U.currentTime + 1 -- one second from now
    if math.random(2) == 1 then self.step = -self.step end
    self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
    self:setRandomTurnTime()
    tween.delay(7, die)
end

We’d best glance at draw as well:

function Saucer:draw()
   pushMatrix()
   pushStyle()
   translate(self.pos.x%WIDTH, self.pos.y%HEIGHT)
   scale(4) -- <---
   stroke(255)
   strokeWidth(1)
   line(-2,1, 2,1)
   line(2,1, 5,-1)
   line(5,-1, -5,-1)
   line(-5,-1, -2,-3)
   line(-2,-3, 2,-3)
   line(2,-3, 5,-1)
   line(5,-1, 2,1)
   line(2,1, 1,3)
   line(1,3, -1,3)
   line(-1,3, -2,1)
   line(-2,1, -5,-1)
   popStyle()
   popMatrix()
end

That handy call to scale needs to say 2, I reckon, to draw a small saucer. So we need a new member variable in the saucer, say size, and it needs to be, let’s say, either 1 or 0.5. We’ll use it this way:

function Saucer:draw()
   pushMatrix()
   pushStyle()
   translate(self.pos.x%WIDTH, self.pos.y%HEIGHT)
   scale(4*self.size) -- <---
   stroke(255)
   strokeWidth(1)
   line(-2,1, 2,1)
   line(2,1, 5,-1)
   line(5,-1, -5,-1)
   line(-5,-1, -2,-3)
   line(-2,-3, 2,-3)
   line(2,-3, 5,-1)
   line(5,-1, 2,1)
   line(2,1, 1,3)
   line(1,3, -1,3)
   line(-1,3, -2,1)
   line(-2,1, -5,-1)
   popStyle()
   popMatrix()
end

What do we want Score to tell us? Probably not the scale number, that’s kind of private. Let’s just ask it a yes or no question. How about asking it drawSmallSaucer.

function Saucer:init(optionalPos)
    function die()
        if self == Instance then
            self:dieQuietly()
        end
    end
    Instance = self
    U:addObject(self)
    self.size = Score:instance():drawSmallSaucer() and 0.5 or 1
    self.shotSpeed = 5
    self.pos = optionalPos or vec2(0, math.random(HEIGHT))
    self.step = vec2(2,0)
    self.fireTime = U.currentTime + 1 -- one second from now
    if math.random(2) == 1 then self.step = -self.step end
    self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
    self:setRandomTurnTime()
    tween.delay(7, die)
end

And …

function Score:drawSmallSaucer()
    return self.totalScore > 3000
end

That’s clean. However, it breaks a unit test.

unit fails

That’s because the test does not have a Score instance to talk with. I’ll see if I can just add one to the test:

        _:test("Saucer added to objects", function()
            _:expect(countObjects()).is(0)
            U.currentTime = ElapsedTime
            Score(4)
            local s = Saucer()
            U:applyAdditions()
            _:expect(countObjects()).is(1)
            s:die()
        end)

That works just as it should. One more little issue, though. The small saucer has its own sound. Good, that gives us a chance to get rid of that fancy code in Saucer:init().

function Saucer:init(optionalPos)
    function die()
        if self == Instance then
            self:dieQuietly()
        end
    end
    Instance = self
    U:addObject(self)
    if Score:instance():drawSmallSaucer() then
        self.size = 0.5
        self.sound = sound(asset.saucerSmallHi, 0.8, 1, 0, true)
    else
        self.size = 1
        self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
    end
    self.shotSpeed = 5
    self.pos = optionalPos or vec2(0, math.random(HEIGHT))
    self.step = vec2(2,0)
    self.fireTime = U.currentTime + 1 -- one second from now
    if math.random(2) == 1 then self.step = -self.step end
    self:setRandomTurnTime()
    tween.delay(7, die)
end

function Saucer:setRandomTurnTime()
    self.turnTime = ElapsedTime + 0.5 + math.random()
end

I had to set the drawing limit to 300 rather than 3000. I’m terrible at this game, especially with the keyboard attached. But it works nicely with a small saucer appearing after the score limit is passed, making its even more irritating sound.

Oh! I forgot to change his kill radius, didn’t I?

function Saucer:killDist()
    return 20
end

That becomes:

function Saucer:killDist()
    return 20*self.size
end

I’m feeling skittish. Commit “small saucer” and let’s sum up.

Summing Up

Why am I feeling skittish? Because I’ve got too many idea balls in the air, and I’m afraid I might drop one. You can’t feel what I feel, but this is very different from the other day where I was just ticking through step after step to make the tests run. Each time I ran the tests, I found one new step on the way to making the tests run right. It’s easy and the tests do all the remembering.

Today, without tests, I had to do all the remembering. Had I written the tests, or even written down a checklist, there’s a good chance that I’d have remembered to check the size when computing kill distance, because I’d have said something like “the saucer becomes smaller and harder to kill” and written it into my notes or test.

Putting it in the test would be far better. It wouldn’t even be that hard, just something like

  local s = Saucer()
  _:expect(s:killDist()).is(10)

But I didn’t do it, because I didn’t start out doing it, because I started out doing that graphical thing and I just kept going.

That doesn’t make me a bad programmer or a bad person (those are due to other causes too numerous to mention). But it does reflect that I’m human, and I need to try to use my best habits, not my poorer ones, wherever I can.

So I’ll slap my wrist a bit again, and try to do better next time. Maybe we’ll even put in the tests we could have done today. That will be fun.

Plus, I deserve some credit for noticing that the tween solution to the saucer turns was getting too complicated. I could have felt a commitment to my good idea, and piled on the programming until it worked. Probably it was the fact that I’d have to explain it here, but whatever it was, I backed away from my clever solution and did a simpler one. Yay, me!

ARRGH, post script! I forgot the different score for the smaller saucer, and haven’t done anything about its increased accuracy. Boo, me!

But yay, anyway, got some good stuff done …

Aside from that, three cool things done. Available ships display, the saucers maneuver now, and we have a small hard to kill little saucer. This game is definitely starting to teach me something … write games that don’t require so much skill to play.

See you next time!

Asteroids.zip