I just can’t get this tune out of my head: I really want to crack missile targeting. I’ve made some observations.

I think part of my problem has been that I’ve been trying to get equivalent accuracy to the hated quadratic. It would be better to have a firing heuristic, a rule of thumb that’s quick and easy to use and understand.

This morning I was thinking about all the pictures I drew with random saucer velocity vectors and realized two things. First, it only flies at a few angles, straight and 45 degrees. Second, the effect of saucer motion is to make a shot aimed at a given point drift forward of that point, or lag behind it, depending … no, wait. Saucer motion always makes the shot drift forward, relative to the saucer motion. That’s a better way of putting it.

So then I drew a little diagram that showed me an obvious fact: the aiming error is zero when aiming is parallel to the movement, and maximized when aiming is perpendicular to movement. What’s zero at zero and one at 90 degrees? The sine function, that’s what. Among quite a few others.

Then I thought about the maximum error due to saucer speed. Saucer speed is presently 2, which equates to 240 pixels per second. From the center of the screen to the edge, 240 pixels is less than 30 degrees. Pi over six radians.

So all that makes me think of a firing approach like this:

  • Predict target location n seconds in the future, maybe 2;
  • Aim at that angle;
  • Adjust aim X degrees in “proportion” to saucer velocity, maybe dot product or sine function;
  • Maybe adjust the above upward or downward based on target distance: more adjustment if further away;
  • Randomize to avoid systematic error, if needed;
  • Fire.

We could even apply those ideas in series, until we get the playing behavior that seems about right. Good targeting, not insanely great.

I think we’ve got to try this. We’re sure to learn something. For my sins, I’ll try some TDD on it.

Naming Aiming

Names are important, and the best name I could think of, Targeter, is already taken, unless I were to decide to delete it right now. And I’m not going to do that, even though I could get it back from Working Copy. I will evolve some kind of class / object.

I don’t think I’ve ever used more than one test tab in Codea, but I think this calls for a second one, because the setup and such is likely to be different.

I’m starting with a stripped version of the other test file, for now. We’ll see what needs to be tweaked:

-- TestAiming
-- RJ 20200624
    
function testAiming()
    --CodeaUnit.detailed = true
    CodeaUnit.oldU = nil

    _:describe("Aiming Tests", function()

        _:before(function()
            CodeaUnit.oldU = U
            U = Universe()
        end)

        _:after(function()
            U = CodeaUnit.oldU
        end)
        
        _:test("Hookup", function()
            _:expect( 2+1 ).is(3)
        end)
    end)
end

I don’t like the fact that “ship” and “saucer” both start with the letter “s”. It makes short names inconvenient, and while I would avoid tiny names in most live code (except for loop indices), I like them in tests, which are generally small.

Additionally, I think I’ll start with simple tables, instead of the existing ship and saucer classes. This should help me isolate the names and structures I really need from everything that’s already in there. We’ll see: we can always use ship and saucer whenever we wish.

Let’s position a few objects and get the pure aiming angle. Here’s a first cut:

        _:test("Pure Aiming Angle", function()
            gun = {pos=vec2(400,400)}
            tgt = {pos=vec2(600,600)}
            aim = Aimer(gun,tgt)
            ang = math.deg(aim:pureAngle())
            _:expect(ang).is(45)
        end)

I figure that directly up and to the right is 45 degrees and this test should run, perhaps requiring an epsilon because floating point.

Naturally there is no Aimer, so let’s create one:

Aimer = class()

function Aimer:init(gun,tgt)
    self.gun = gun
    self.tgt = tgt
end

function Aimer:pureAngle()
    local toTarget = self.tgt.pos - self.gun.pos
    return toTarget:angleBetween(vec2(1,0))
end

A few typos have been skipped above. The result surprises me, it’s -45. That tells me that the angleBetween parameters are probably reversed from what I expected.

function Aimer:pureAngle()
    local toTarget = self.tgt.pos - self.gun.pos
    return vec2(0,0):angleBetween(toTarget)
end

So that’s that. Let’s do a couple more just to get the lay of the land:

        _:test("Pure Aiming Angle -45", function()
            gun = {pos=vec2(400,400)}
            tgt = {pos=vec2(600,200)}
            aim = Aimer(gun,tgt)
            ang = math.deg(aim:pureAngle())
            _:expect(ang).is(-45)
        end)

OK, I’m happy with that. Now since I’ve been thinking about angles, I think I’ll keep the Aimer working in angles up until its final return, which may want to be a direction.

As I think about what comes next, I think about the fact that we want the target to be a future position of the ship. We could compute that inside Aimer, but let’s try keeping it focused just on the angles of fire. We’ll probably wind up changing its starting parameters. In fact, let’s do that now. Right now it just needs positions, let’s just pass those in instead of these complex tables.

The test is helping me focus on the calling sequence and usage, and there’s no better time to evolve it than during this definition phase.

        _:test("Pure Aiming Angle 45", function()
            gun = vec2(400,400)
            tgt = vec2(600,600)
            aim = Aimer(gun,tgt)
            ang = math.deg(aim:pureAngle())
            _:expect(ang).is(45)
        end)
        
        _:test("Pure Aiming Angle -45", function()
            gun = vec2(400,400)
            tgt = vec2(600,200)
            aim = Aimer(gun,tgt)
            ang = math.deg(aim:pureAngle())
            _:expect(ang).is(-45)
        end)

And

Aimer = class()

function Aimer:init(gunPos,tgtPos)
    self.gunPos = gunPos
    self.tgtPos = tgtPos
end

function Aimer:pureAngle()
    local toTarget = self.tgtPos - self.gunPos
    return vec2(0,0):angleBetween(toTarget)
end

So that’s fine.

The phase one idea was just to fire at the future position, and that’s what this little guy does. I want to plug it into the saucer and try it.

That’s going to involve a diversion, I suspect, to make the asteroids harmless so that I can watch the saucer and ship interact. We’ll see.

In Saucer:

function SaucerMissile:fromSaucer(saucer)
    if not Ship:instance() or math.random() > saucer:accuracyFraction() then 
        return SaucerMissile:randomFromSaucer(saucer) 
    end
    local targ = Targeter(saucer, Ship:instance())
    local dir = targ:fireDirection()
    bulletStep = dir*saucer.shotSpeed + saucer.step
    return SaucerMissile(saucer.pos, bulletStep)
end

I think I’ll do this:

function SaucerMissile:fromSaucer(saucer)
    if (true) then
        return SaucerMissile:aimed(self)
    end
    if not Ship:instance() or math.random() > saucer:accuracyFraction() then 
        return SaucerMissile:randomFromSaucer(saucer) 
    end
    local targ = Targeter(saucer, Ship:instance())
    local dir = targ:fireDirection()
    bulletStep = dir*saucer.shotSpeed + saucer.step
    return SaucerMissile(saucer.pos, bulletStep)
end

I’ll just override all the behavior for now and then:

function SaucerMissile:aimed(saucer)
    if not Ship:instance() then
        return SaucerMissile:randomFromSaucer(saucer)
    end
    local gunPos = saucer.pos
    local ship = Ship:instance()
    local tgtPos = ship.pos + ship.step*120
    print(gunPos,tgtPos)
    local aim = Aimer(gunPos,tgtPos)
    local ang = aim:pureAngle()
    local bulletStep = vec2(saucer.shotSpeed, 0):rotate(ang)
    return SaucerMissile(gunPos, bulletStep)
end

This is a bit less smooth than we’d like the calling sequence to be but with just pureAngle, the shots are uncannily accurate. Even with the ship moving, they’re close, and if you slow or come to a stop, you’ve had it. I’m thinking that in game play this may be fine as it stands.

There’s something a bit ironic about this, subject to the fact that I’m never sure quite what irony is. I researched and built a complicated quadratic solver that I didn’t fully understand. It works well, almost too well. Then I devised several cunning schemes of my own, fortunately committing none of them to code. Finally, after literally hours of thinking and sketching, I come up with an idea that amounts to “how about if we aim at where he’s going to be in a couple of seconds” and it works nicely.

It’s not that much like rain on your wedding day, but it’s good stuff. I’m going to rip out the targeter entirely and go with this. I’m sure the Customer (me) will agree and be happy.

Commit: Add Aimer, remove Targeter.

I’m going to sum up and call it a morning.

Summing Up

I’ve left this code a bit dirty and should certainly clean it up when next I’m in the area. In particular, Aimer is compiled into the TestAiming tab. Often I’ll build at least the shell of a working class right in the testing tab. It’s convenient and helps me focus. I believe I stole the idea from Keith Braithwaite and his “TDD As If You Meant It” training. If I got it wrong, it’s my fault, not his.

It’s tempting for some of us to look back on this and think how awful it was that we wasted all that research and all that code translation and all that testing and all that thinking, just to finally wind up with this nearly trivial approach.

On the contrary. It’s the research and thinking and trying and being willing to change that results in simple solutions like this one.

To me, today’s few minutes of work justifies the few days of “discarded” work that made this success possible.

A good day at last. And now I can get that darn tune out of my head.

Asteroids.zip