Today I want to put in a toggle to give the saucers permission to shoot accurately all the time, and I’d like to get a start on hyperspace. That doesn’t seem like too much to ask.

The toggle idea is simple. Right now, the saucers shoot accurately only once in a while:

function Saucer:accuracyFraction()
    return self.size == 1 and 1/20 or 4/20
end

Now, in the “real” game, the large saucer always shoots randomly, and the small one shoots randomly once and thereafter shoots accurately. I’ve got us set so that the large one shoots accurately about 1 in 20 times, and the small, 4 in 20. And there’s no provision for one random shot at the beginning.

Now that I think about it, let’s set up for a guaranteed random shot as the first one, rare good shots from the large saucer, and full time good shots from the small, after the first.

SaucerMissile firing looks like this:

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

function SaucerMissile:fromSaucer(saucer)
    local ship = Ship:instance()
    if not ship or math.random() > saucer:accuracyFraction() then 
        return SaucerMissile:randomFromSaucer(saucer) 
    else
        return SaucerMissile:aimedFromSaucer(saucer,ship)
    end
end

function SaucerMissile:randomFromSaucer(saucer)
    local rot = math.random()*2*math.pi
    local pos = saucer.pos + vec2(saucer:killDist() + 1, 0):rotate(rot)
    local vel = U.missileVelocity:rotate(rot) + saucer.step
    return SaucerMissile(pos, vel)
    
function SaucerMissile:aimedFromSaucer(saucer, ship)
    local gunPos = saucer.pos
    local tgtPos = ship.pos + ship.step*120
    local toTarget = tgtPos - gunPos
    local ang = vec2(1,0):angleBetween(toTarget)
    local bulletStep = vec2(saucer.shotSpeed, 0):rotate(ang)
    return SaucerMissile(gunPos, bulletStep)
end

I think the fromSaucer method belongs on Saucer. The Saucer should decide what kind of missile it wants, not the missile. Then we’ll have the two missile types, random and aimed, that the saucer will use. I’ll remove the basic fromSaucer and plug it into Saucer thusly:

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
        self:fireMissile()
    end
end

function Saucer:fireMissile()
    local ship = Ship:instance()
    if self.firstShot or not ship or math.random() > self:accuracyFraction() then 
        self.firstShot = false
        return SaucerMissile:randomFromSaucer(self) 
    else
        return SaucerMissile:aimedFromSaucer(self,ship)
    end
end

I’ve initialized firstShot to true in Saucer:init(). This works, with a failing test:

26: Small Saucer shoots at 4/20 accuracy -- TestAsteroids:279: attempt to call a nil value (method 'fromSaucer')

That’s our irritating random test:

        _:test("Small Saucer shoots at 4/20 accuracy", function()
            U = FakeUniverse()
            local score = Score(3)
            score:addScore(3000)
            local ship = Ship(vec2(400,800))
            local saucer = Saucer(vec2(500,800))
            local count = 0
            for i = 1,1000 do
                local m = SaucerMissile:fromSaucer(saucer)
                if m.step.y < 0.001 and m.step.y > -0.001 then
                    count = count + 1
                end
            end
            saucer:dieQuietly()
            _:expect(count).is(200,50)
        end)

I’m going to make it work, and then I think I’ll remove it because it’s not doing much good.

        _:test("Small Saucer shoots at 4/20 accuracy", function()
            U = FakeUniverse()
            local score = Score(3)
            score:addScore(3000)
            local ship = Ship(vec2(400,800))
            local saucer = Saucer(vec2(500,800))
            _:expect(saucer.size).is(0.5, 0.0001)
            local count = 0
            for i = 1,1000 do
                local m = saucer:fireMissile()
                if m.step.y < 0.001 and m.step.y > -0.001 then
                    count = count + 1
                end
            end
            saucer:dieQuietly()
            _:expect(count).is(200,50)
        end)

Nah. Never remove a test without a good reason. We’ll keep it.

Now for the perfect accuracy toggle. First the parameter, in setup:

    parameter.boolean("PerfectSaucerShots", false)

Then we should be able to plug it into fireMissile:

function Saucer:fireMissile()
    local ship = Ship:instance()
    if self.firstShot or not ship or math.random() > self:accuracyFraction() then 
        self.firstShot = false
        return SaucerMissile:randomFromSaucer(self) 
    else
        return SaucerMissile:aimedFromSaucer(self,ship)
    end
end

That’s already a bit messy. If we’re willing to forego perfect accuracy on the first shot (and we are), we would do better to plug this into accuracyFraction, which looks like this:

function Saucer:accuracyFraction()
    return self.size == 1 and 1/20 or 4/20
end

Let’s do this instead of something fancy:

function Saucer:accuracyFraction()
    if PerfectSaucerShots then return 1 end
    return self.size == 1 and 1/20 or 4/20
end

That works nicely. The accurate shots, using our new simpler algorithm, are dead on if the ship isn’t moving, and usually lag if it is moving. That’s consistent with the old game, where you avoided the accurate shots of the little saucer by scooting around. We might look at changing the lead amount based on the general distance away, just for fun. But that’s for another day.

I think this is good. Commit: “PerfectSaucerShots and a little refactoring”.

Hyperspace

The game has an additional button for emergencies, “Hyperspace”. When you press this button, the ship disappears from the screen into “hyperspace”. It is invulnerable during this period. After an interval of a few seconds, the ship reappears randomly on the screen. About one in six times, the reentry from hyperspace causes the ship to explode.

  • Experts vary on whether hyperspace has a chance of killing the ship. We can readily put that on a toggle or a slider.
  • There may be a refractory period before you can use hyperspace again. We’ll assume there is not, at least for now.
  • The ship might return at zero velocity, or it might retain its real space velocity and orientation upon return. I’m not sure what the original did, but I like retaining real space velocity and orientation.

I want to focus on two aspects of this. First, the ship is “no longer there”, which we can accomplish by not drawing it. But second, it can’t be destroyed. I’m inclined to handle that by placing the ship at a large coordinate distant from the screen, so that nothing will collide with it. One might argue that this is a hack. I argue that it’s the proper definition of hyperspace: being at coordinates outside space.

What about triggering it? I think the ship already handles its own turning and acceleration. Let’s see:

function Ship:move()
    if U.button.turn then self:turn() end
    if U.button.fire and not self.holdFire then self:fireMissile() end
    if not U.button.fire then self.holdFire = false end
    self:actualShipMove()
end

So we could detect the button here and call enterHyperspace. Let’s start with the button, I guess.

function createButtons()
    local dx=75
    local dy=200
    local cen = vec2(125,125)
    U.button.turnCenter = cen
    table.insert(Buttons, {x=cen.x, y=cen.y, radius=125, name="turn"})
    table.insert(Buttons, {x=WIDTH-dx, y=dy, radius=dx, name="fire"})
    table.insert(Buttons, {x=WIDTH-dy, y=dx, radius=dx, name = "go"})
end

So, I don’t know, let’s put a button near the turn button, since the right hand is busy with firing and accelerating already. We know we may want to improve the controls, but this should do for now.

function createButtons()
    local dx=75
    local dy=200
    local cen = vec2(125,125)
    U.button.turnCenter = cen
    table.insert(Buttons, {x=cen.x, y=cen.y, radius=125, name="turn"})
    table.insert(Buttons, {x=WIDTH-dx, y=dy, radius=dx, name="fire"})
    table.insert(Buttons, {x=WIDTH-dy, y=dx, radius=dx, name = "go"})
    table.insert(Buttons, {x = cen.x + 250, y = cen.y-25, radius=100, name = "hyperspace"})
end

A bit of fiddling and it looks like this:

hyper

Now to make it work. Is there any useful way to test this? I think not yet. I’ll work by intention as much as I can:

function Ship:move()
    if self.realSpace then
        if U.button.turn then self:turn() end
        if U.button.fire and not self.holdFire then self:fireMissile() end
        if not U.button.fire then self.holdFire = false end
        self:actualShipMove()
    end
end

I just decided that we have a ship state flag realSpace. If we’re in real space we can move, otherwise not. I’ll init that in Ship:init():

function Ship:init(pos)
    self.pos = pos or vec2(WIDTH, HEIGHT)/2
    self.radians = 0
    self.step = vec2(0,0)
    self.realSpace = true
    Instance = self
    U:addObject(self)
end

And then lets get into hyperspace:

function Ship:move()
    if self.realSpace then
        if U.button.hyperspace then self:enterHyperspace() end
        if U.button.turn then self:turn() end
        if U.button.fire and not self.holdFire then self:fireMissile() end
        if not U.button.fire then self.holdFire = false end
        self:actualShipMove()
    end
end

And surely we’ll want to set the flag, start a timer, probably a tween, and we need to save and restore the position. No, wait, we don’t save, we return to a random new position. Since the controls are ineffective when we’re not in real space, that’s all we need to do, I think.

Here’s what I’ve got.

function Ship:enterHyperspace()
    local appear = function()
        self.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
        self.realSpace = true
    end
    self.realSpace = false
    self.pos = vec2(10000,10000)
    tween.delay(6,appear)
end

Now we need not to draw the ship, or it’ll just appear at (10000,10000) clipped to screen coordinates.

function Ship:drawAt(pos,radians)
   if not self.realSpace then return end
... etc

I’m goin in. Hold my granola bar …

Well, this is weird. The ship goes away, but it doesn’t reappear on the screen. I think it’s there, because I’m seeing asteroids breaking up. Also, I don’t know how long this has been going on, I’m not seeing my remaining ships on the screen, nor do new ships spawn. Finally, the hyperspace button stays red, suggesting its still active.

I’ll do a quick look around to see if there’s something obvious, otherwise I’m gonna revert.

Yes, that was easy. We init all the buttons to false in checkButtons, and I didn’t do that. Fixed:

function checkButtons()
    U.button.left = false
    U.button.right = false
    U.button.turn = false
    U.button. turnPos = nil
    U.button.go = false
    U.button.fire = false
    U.button.hyperspace = false
    for id,touch in pairs(Touches) do
        for i,button in ipairs(Buttons) do
            if touch.pos:dist(vec2(button.x,button.y)) < button.radius then
                U.button[button.name]=true
                if button.name == "turn" then
                    U.button.turnPos = touch.pos
                end
            end
        end
    end
end

Now it works fine. Ship appears randomly and retains velocity. It’s hard to spot where it has arrived. We might want to make a little special effect for that.

But the fresh ships are definitely not showing up. I am not sure whether they were at the previous commit or not. I’ll commit this and then load that one and look around, but I think this is good enough to keep.

Commit: “initial hyperspace”

Where’d they go?

I think I know. We use Ship:drawAt() to draw them. We have it conditioned not to draw if we’re not in real space.

Let’s look at how that’s done. First, I’ll check out the trunk again. Ah, this is a bit of hackery:

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

We call the drawAt method on the class without making an instance. That means that the member variable realSpace is not initialized, therefore false. Let’d move the check up to draw:

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

I expect this to work now … and it does.

Let’s commit this: “restore free ship drawing”

After Some Gameplay

After a bit of playing with the game, I decided that it’s too hard to see where the ship comes back, so I added an effect:

function Ship:enterHyperspace()
    local appear = function()
        self.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
        self.realSpace = true
        self.scale = 10
        tween(1, self, {scale=2})     
    end
    self.realSpace = false
    self.pos = vec2(10000,10000)
    tween.delay(6,appear)
end

When the ship comes back, it appears large and shrinks quickly down to normal size, sort of as if it were dropping in from above the screen. Cute.

I also observed that the ship was behaving in hyperspace as if it was somewhere in the upper left corner of the screen. That was due to the fact that the enterHyperspace call was dropping through the rest of the move routine. The move routine calls universe’s move function, which clips the ship back to the screen. Fix was this:

function Ship:move()
    if self.realSpace then
        if U.button.hyperspace then 
            self:enterHyperspace()
            return
        end
        if U.button.turn then self:turn() end
        if U.button.fire and not self.holdFire then self:fireMissile() end
        if not U.button.fire then self.holdFire = false end
        self:actualShipMove()
    end
end

I don’t like the look of that much. How about this:

function Ship:move()
    if self.realSpace then
        if U.button.hyperspace then
            self:enterHyperspace()
        else
            if U.button.turn then self:turn() end
            if U.button.fire and not self.holdFire then self:fireMissile() end
            if not U.button.fire then self.holdFire = false end
            self:actualShipMove()
        end
    end
end

That seems better. Commit: “ship arrival effect, fix kill on hyperspaced ship”.

arrival

Summing Up

The final official feature is in, in its initial form. It’s pretty close to what we need, unless we want variable time to return and the occasional tragic hyperspace accident. Even so, the “hard” part is done and it wasn’t hard at all.

There were a few surprises along the way today, but I don’t see much in the way of automated testing that could have helped. I’ll reflect on that, though. Certainly today I was not as relaxed as I am when test-driving. When we come to a larger summing up, I’ll try to reflect on how more of this could have been done in the TDD style.

For now, today, another new feature. New capabilities every couple of hours? What’s not to like?

See you next time!

Asteroids.zip