Asteroids 48
Let’s finish up the small saucer, and how about testing it this time?
The small saucer mostly works, except for the bits I forgot, like the fact that you’re supposed to get more points for it, and that it’s supposed to be more accurate in its firing.
At least one reason why I forgot is that I didn’t start off by stating the “story” for the small saucer, but I rarely do that formally here. Maybe I should.
One good way to express a story is in one or more tests, and I didn’t do that either. Today, we’ll see what happens if we remedy that.
The small saucer starts to appear after a certain threshold of score is reached, such as 3000 points. Once that threshold is reached, only the small saucer appears: the large one is not seen again. The small one is one half the size of the large, and thus much harder to hit. It shoots much more accurately. We have such high accuracy in our targeted shots now that we’ll have to tune that or come up with a new targeting algorithm. That will be a separate story. For now, make it four times as accurate, using targeting 4 out of 20 shots instead of one. Hitting the small saucer with a missile scores 1000 points, unlike the large, which score 250.
I think that’s a pretty decent story. Let’s begin with a test that addresses the issues that we can. Here’s a start:
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
_:expect(s.size).is(1)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
s:dieQuietly()
end)
We just create a saucer, check its size, up the score, create another. Guess what: the test fails. We get size 1. The code for the threshold is this:
function Score:drawSmallSaucer()
return self.totalScore > 3000
end
Checking with the Customer (me), we get the answer that yes, 3000 should trigger a small saucer. I don’t like that method name, either. I’ll change it as well:
function Score:shouldDrawSmallSaucer()
return self.totalScore >= 3000
end
Much better name. After a corresponding change in the saucer creation, the test passes.
I did run into another issue, which is a Codea thing, not our program’s thing. I slid some tabs around to allow me quicker switching between the test and the Saucer and other objects I need quick access to. I moved SaucerMissile to the left of Missile. But SaucerMissile inherits from Missile, but since Codea compiles from left to right, it wasn’t inheriting properly. I’m starting to think that we should consolidate some of these tabs anyway, and this makes it a bit more important to do. It’s easy to fix, but it’s a pain.
Back to our story and out test. We checked the size. Let’s check the kill distance also. I’ll keep enhancing this single test for a while, because it’s telling a story.
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
_:expect(s.size).is(1)
_:expect(s:killDist()).is(20)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
_:expect(s:killDist()).is(10)
s:dieQuietly()
end)
I expect this to fail, because I didn’t remember dealing with kill distance yesterday. But we did, and the test passes. Nice. Now for score:
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
local m = Missile()
_:expect(s.size).is(1)
_:expect(s:killDist()).is(20)
_:expect(s:score(m)).is(250)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
_:expect(s:killDist()).is(10)
_:expect(s:score(m)).is(1000)
s:dieQuietly()
end)
As expected:
30: small saucer properties -- Actual: 250, Expected: 1000
We have:
function Saucer:score(anObject)
if anObject:is_a(Missile) then
return 250
else
return 0
end
end
I’m just going to jam that in place, but I’m starting to think that a lot of these magic numbers really need to be centralized where we can adjust them.
function Saucer:score(anObject)
if anObject:is_a(Missile) then
return self.size == 1 and 250 or 1000
else
return 0
end
end
Test passes. The only thing not tested is the accuracy part of the story, that the large guy should be accurate one time in 20 and the small four times in 20. Right now, there’s no way to see the intended accuracy. The relevant code looks like this:
function SaucerMissile:fromSaucer(saucer)
if not Ship:instance() or math.random(20) < 20 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
We could implement this, somehow, as one of 20 or four of 20, or we could do the math and make it one of five in the small saucer case. I would argue that we should do the former, for two reasons.
First, the code should express the story, and the Customer said four of 20. Second, if the Customer had said seven of 20, we couldn’t reduce the fraction.
So we resolve to represent the two notions 1::20 and 4::20 in the code, as clearly as we can.
We still have at least two options. We could adjust the 20 in the check above, down to 17 instead of 20. If we changed the less-than to less than or equal, we could make the code more clear.
Or, we could represent the values as fractions, 1/20 and 4/20, and roll a random between 0 and 1 and use the fraction. That would be clear, and quite flexible, since 7/20 would work, and even 13/43.
Having said that, the choice seems clear to me, we’ll do the latter.
Let’s posit a new method to return the ratio, and call it in our test:
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
local m = Missile()
_:expect(s.size).is(1)
_:expect(s:killDist()).is(20)
_:expect(s:score(m)).is(250)
_:expect(s:accuracyFraction()).is(1/20)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
_:expect(s:killDist()).is(10)
_:expect(s:score(m)).is(1000)
_:expect(s:accuracyFraction()).is(4/20)
s:dieQuietly()
end)
Now this is a bit fragile, though I’m sure it’ll work with the implementation I have in mind. The reason is that those fractions are floats and floats don’t come out even. So I’ll put in an allowed error:
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
local m = Missile()
_:expect(s.size).is(1)
_:expect(s:killDist()).is(20)
_:expect(s:score(m)).is(250)
_:expect(s:accuracyFraction()).is(1/20, 0.001)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
_:expect(s:killDist()).is(10)
_:expect(s:score(m)).is(1000)
_:expect(s:accuracyFraction()).is(4/20, 0.001)
s:dieQuietly()
end)
Now to install that method, and use it. I’m not sure how to test that it’s in use, so I’ll be careful:
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
function Saucer:accuracyFraction()
return self.size == 1 and 1/20 or 4/20
end
And the tests run.
Narrator (v.o.): But is the code correct? We’ll see …
Are you troubled by that logic expression? It’s idiomatic Lua, and well-accepted in the Jeffries coding standard. The and
binds tighter than or
, so it is equivalent to:
return (self.size == 1 and 1/20) or 4/20
Everything non-zero and non-nil is truthy, and if size is 1, the and
expression returns the last value, in this case 1/20. Since that’s “true”, the or does not run. If size is not , the rest of the and is skipped and the expression returns the result of the
or` part, in this case 4/20.
Yes, it’s weird. It’s also compact and it’s the kind of thing one says when one speaks Lua. If you were here, you could vote on the standard, but as things stand, it’s down to me and Kitty and Kitty said she could go either way, as long as she gets food and naps.
Anyway, the tests run. Let’s commit: small saucer tests run.
What Now?
We might be done, but let’s at least think about what’s still on the board.
The list from #46 includes these as yet incomplete stories:
- Hyperspace
- Missiles destroy Saucer Missiles
- Less Accurate “Accurate”
- Improve overall timing logic
- Game Adjustment switches, dials, etc
- Portrait Mode
- Improved Turning Controls
- Pluggable Controls
None of these is too thrilling. To do poorer accuracy, I need some time with Pencil™ and Paper™. I have two ideas:
- Divide the space around the saucer into arcs, maybe 10 or 15 degrees. Predict which space the ship will be in, and fire into that area.
- Reduce the accuracy of our ability to sense ship location and speed. We could do that by twiddling the true numbers a bit. That would introduce error into the calculation.
- (I lied about the 2.) Just toss some error into the final firing direction.
The first solution is close to what the original does, to the extent that I can decode the 6502 assembler. So it would have some authenticity. The other two solutions are both pretty ad hoc, with the second one making at least a bit of sense. I suppose the barrel on the Saucer might be wearing out, introducing error upon firing.
Anyway I rather prefer the first, having a poor algorithm, rather than having a good one and then breaking it. Be that as it may, I need more think time on that.
Hyperspace is certainly the biggest remaining issue in working like the original. Let’s see if we can write a story for it:
Hyperspace
The ship pilot has a Hyperspace button. When she presses it, the ship vanishes instantly from wherever it is, and after an interval, returns at a random position in space. The interval is probably around two seconds, and it should probably vary a little bit to make it a bit harder for the pilot to regain the picture. There is another short interval for the hyperspace engines to recharge before they can be used again. That interval is probably also a couple of seconds, but is independent of the return delay. Hyperspace never fails, but it can bring you back anywhere, including right in front of, or inside, an asteroid.
Let’s think about what we’d need for this.
- A button
- I’m not sure where to put this, since the controls are already awkward, but probably like a space bar across the bottom of the screen, so it can be reached by thumb or finger on either hand.
- Charging
- A simple flag could work for this, triggered by a countdown in the ship, or even a tween. This does bear on the need to get timed events better organized.
- Entering Hyperspace
- I reckon we can just check the button, as we do for firing missiles and running the engines. I expect nothing special here, at least until we look around.
- In Hyperspace
- I think we could just move the ship’s position offscreen to avoid collisions, but we’ll have to have an ‘in hyperspace” flag of some kind to make it not be drawn. Alternatively, I suppose we could destroy it silently and create a new one. That seems kind of weird to me.
- Coming Back
- Another timer, variable length, and code to pick a random position and bring the ship back to visible.
Offhand, that seems like just about all we need. As for tests, at this moment I don’t see much of anything we can reach with CodeaUnit. We’ve not really cracked timing tests at all.
Looking at all that, it seems to me to be more than we want to undertake this morning. I think we’ll sum up and take the rest of the day for other things.
Summing Up
Did you notice how writing down the story helped a bit, and that putting that story into the test helped a lot? It seems always to go that way. The test guides our attention to the next item, and we just tick through them.
If you’ve not taken a look at this approach, most often called TDD, or Test-Driven Development, I highly recommend it. If you are familiar with TDD, then you are probably shouting at me sometimes WHERE IS THE TEST FOR THAT.
And rightly so.
All in all, a productive morning. Have a good day, and I’ll see you next time!
Wait! Wait, wait, wait!
Wait! I started playing the game and the REDACTED large saucer hit me every time. We have a bug in that bit of the code we didn’t test. Let’s see what’s up.
Narrator (v.o.): <clears throat meaningfully>
_:test("small saucer properties", function()
U = FakeUniverse()
local score = Score(3)
local s = Saucer()
local m = Missile()
_:expect(s.size).is(1)
_:expect(s:killDist()).is(20)
_:expect(s:score(m)).is(250)
_:expect(s:accuracyFraction()).is(1/20, 0.001)
s:dieQuietly()
score:addScore(3000)
s = Saucer()
_:expect(s.size).is(0.5)
_:expect(s:killDist()).is(10)
_:expect(s:score(m)).is(1000)
_:expect(s:accuracyFraction()).is(4/20, 0.001)
s:dieQuietly()
end)
OK the fraction is right. But are we using it correctly?
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
Right. We return a random missile only if the odds are incredibly against it. That if statement is backward. I’ll fix it, but how can we test it?
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
We have some tests for the Targeter. The way things are now, Targeter is referred to directly in the SaucerMissile, so we can’t plug in a fake one and use it to see what’s up. SaucerMissiles are the same whether targeted or not. We could put a flag in them to say whether they are targeted or not, but we have no other use for that flag than our tests.
That’s not to say that I wouldn’t do that, but I’d rather test something that’s germane to the situation.
What if we were to position a ship and saucer carefully, then repeatedly call for a new missile, and check how often we got a targeted one? It’d be stochastic but it ought to work fairly well.
For my sins, I’ll try it.
_: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 then
count = count + 1
end
end
saucer:dieQuietly()
_:expect(count).is(200,50)
end)
We fire 1000 missiles and check the ones that are aimed horizontally. This test runs, though of course there is a small chance that it will fail once in a while.
In making this work, I noticed that saucer missiles move faster than ship missiles. We should look into that and see why we made that decision. It’s probably a mistake.
OK, well, not a perfect day after all. But all’s well that ends, that’s my motto. See you next time!