Asteroids 9: Drawing the rocks.
I found the definitions of the asteroid shapes in DVG language. Let’s draw them in Codea.
I did the initial munging of the asteroids in Sublime Text, my Mac editor of choice. I’ve extracted just the info I need, the scale, and the x and y. That’s enough to draw the asteroids as they originally were, but it’s not ideal.
The asteroids will be redrawn in the Codea draw()
event, about 50 times a second or something like that. So we’d like the code to be as tight as it can reasonably be. It’s not a big concern: the iPad is probably a thousand times faster than the 6502 and its Digital Graphics Generator, but there’s no reason to do extra work in the innermost loops.
Here’s what needs doing. We should fold the scale into the x and y values, to save doing that every time around. And we need to deal with the fact that Codea’s line-drawing is direct, from point A to point B, while the DVG’s was incremental, from wherever you are to point B. Let’s take a simple example.
If the DGV was told to draw a line (1,1) and then another line (1,-1), and it was starting at (0,0), you’d get a line from (0,0) to (1,1) and then a line from (1,1) to (2,0). The values accumulate. In Codea, to get the same effect, we’d have to say:
line(0,0, 1,1)
line(1,1, 2,0)
In the code I wrote to spike the drawing, I handled it this way:
for i,v in ipairs(a) do
local n = 1
local t = from+v
line(n*from.x, n*from.y, n*t.x,n*t.y)
from = vec2(t.x,t.y)
end
Each step through adds the next step v
to the from
location, draws the line, then saves the new target t
as from
. There’s other hackery in the drawNewAsteroid
function, because there’s one other little glitch. The data provided draws each asteroid starting from its nominal center. The first line drawn is drawn at zero brightness, so the beam skips to the border. I hacked that by starting, not at 0,0, but at 0,1 in my example. And I had adjusted for scale explicitly with a division in the data definition.
It was a spike, a rough slam through the problem, to learn how to do it. That code needs to be thrown away and in fact I will replace it with what we do here. For a nice little recent thread on spikes, see this from GeePaw Hill. It’s well worth a read.
So, what to do. I’ve got data that looks like :
-- Rock Pattern 1
local R1 = {
{s=3, x=0, y=1},
{s=3, x=1, y=1},
{s=3, x=1, y=-1},
{s=2, x=-1, y=-2},
{s=2, x=1, y=-2},
{s=2, x=-3, y=-2},
{s=2, x=-3, y=0},
{s=3, x=-1, y=1},
{s=3, x=0, y=2},
{s=3, x=1, y=1},
{s=3, x=1, y=-1}
}
The scale rule is that the 3’s values should be double those of the 2’s. (Or the 2’s should be half the 3’s, which is how I did it in the spike. and then the values should be cumulative. Those changes are beyond my limited regex skill, so my plan is to do them in Codea. A wise man might TDD this. I am not a wise man, and I don’t see much value to TDDing, so my current plan is just to do the job.
I’m going to do it in a new little Codea app, because I have an idea that might be semi-useful. We’ll see.
New project: ConvertRocks.
First, I paste the rock definitions into a new tab “Rocks”, and remove the local
words. That makes four globals R1, R2, R3, and R4. Now in Main, my plan is just to convert them and store them back into another tab. I may or may not worry about formatting. One thing at a time.
OK, I should have set a timer. It has been about ten minutes and my proposed hackery still hasn’t properly hacked a single rock. So, only somewhat fool here, I’ll link in CodeaUnit and try to do this right.
function testCodeaUnitFunctionality()
CodeaUnit.detailed = true
_:describe("CodeaUnit Test Suite", function()
_:before(function()
-- Some setup
end)
_:after(function()
-- Some teardown
end)
_:test("hookup", function()
_:expect("bar").is("Foo")
end)
end)
end
The hookup fails. Let’s see what we can do here.
_:test("cumulate unscaled", function()
prev = {x=2,y=3}
data = {s=1, x=2, y=4}
next = cumulate(prev,data)
_:expect(next.x).is(4)
_:expect(next.yu).is(7)
end)
This test is informed by my prior messy attempt. The idea is that I’ll write a function cumulate, which, given the previous answer, and a data value from the rock table, returns the sum of the previous and data.
Should be easy:
function cumulate(prev, data)
return {x = prev.x + data.x, prev.y + data.y}
end
Remember, I try to show you all my mistakes. The current mistake is that I forgot to put the y =
in the second item. Full disclosure: it’s not the first time I’ve ever done that. It might be best to convert to using vec2
which doesn’t require me to type that in. For now, just this:
function cumulate(prev, data)
return {x = prev.x + data.x, y = prev.y + data.y}
end
Ahem. This typo had slipped past me:
_:expect(next.yu).is(7)
I fumble-fingered a “yu” where I needed “y”. Now then … my test runs.
Now you may have been thinking that first of all, you could have just typed in the conversion and been done with it, and second, even if it had a couple of bugs you could have debugged it. And you’re probably right. Me, I’m a bit different.
First of all, in my defense, I’m writing this article at the same time as I write the code, so I’m surely operating at a disadvantage. Second, I am not a clever man, so I make more mistakes than you might. Third, I’ve learned to notice really quickly when I’m about to step into a deep pit of debugging, and to turn to writing micro tests to give me the confidence that the code actually works.
If you were to practice TDD as much as I have, what would your findings be? Will you ever know? There’s only one way to be sure: to try it. So maybe, when you have some time, you’ll give it a go. If not, that’s OK too. I’m not the cops.
OK, this tiny test runs.
What’s next? Oh, yeah, scaling.
Because I rather like the idea of converting to vec2
and away from these tables with an x an y in them, I think I’ll make my scaling function take the table with s, x, y return a scaled vector, which will then be used to do the cumulation. Let’s write a new function that scales, returning a vec2
This time I think I’ll expand the input into the parameters of the function: that might be better. We’ll need two tests.
Well, writing the first test told me that I didn’t like unwinding the little table in the caller. Better to let the guy who uses the inner bits unwind them. Here’s the first test and the implementation so far:
_:test("scale 2 returns unchanged", function()
local input = {s=2,x=1,y=2}
local new = scaled(input)
_:expect(new.x).is(1)
_:expect(new.y).is(2)
end)
function scaled(input)
return vec2(input.x, input.y)
end
Notice that I didn’t do any scaling in the scaled
function yet. That’s because the test doesn’t require it. The pattern is called “fake it till you make it”, and consists of writing a trivial implementation of a trivial test, then filling it in when the next test requires a more full implementation.
The advantage to that is that it lets me focus first on the overall shape of the solution, basically the calling sequence, and then separately on its internal details. Try it, you might like it. Now another test and the full implementation:
_:test("scale 3 returns double", function()
local input = {s=3,x=1,y=2}
local new = scaled(input)
_:expect(new.x).is(2)
_:expect(new.y).is(4)
end)
function scaled(input)
local mul = (input.s~=3) and 1 or 2
return vec2(input.x, input.y)*mul
end
Do you hate that ternary calculation of mul
? Well, I don’t, but you might, in which case you’d change it. Maybe you’d prefer
if input.s==3 then
return vec2(input.x, input.y)*2
else
return vec2(input.x, input.y)
end
Horses for courses, and to each his own, and so on. Anyway that works. Now to combine them into the code we want:
_:test("unscaled cumulated", function()
local prev = vec2(2,3)
local data = {s=1, x=2, y=4}
local next = cumulateScaled(prev, data)
_:expect(next.x).is(4)
_:expect(next.y).is(7)
end)
function cumulateScaled(prev, data)
return cumulate(prev, scaled(data))
end
That runs. Now for the scaled case:
_:test("scaled cumulated", function()
local prev = vec2(2,3)
local data = {s=3, x=2, y=4}
local next = cumulateScaled(prev, data)
_:expect(next.x).is(6)
_:expect(next.y).is(11)
end)
And all my tests run. So I’m confident that my little functions here are doing what I want. Now then … should I apply them to create the new tables directly, or should I TDD the loop?
Should be no harm in TDDing it, since I’ll just build the code I need in the test, so at most the time I waste will be in writing the test. And I’ll create a simple table and test that. At least that’s my cunning plan.
_:test("do a table", function()
local intab = {
{s=2, x=1, y=2},
{s=3, x=3, y=4},
}
local outab = convertTable(intab)
_:expect(#outab).is(2)
local last = outab[2]
_:expect(last.x).is(7)
_:expect(last.y).is(10)
end)
function convertTable(tab)
local result = {}
local prev = vec2(0,0)
for i, t in ipairs(tab) do
local next = cumulateScaled(prev, t)
table.insert(result, next)
prev = next
end
return result
end
That runs. And I think it might be right. But honestly, I’m still not entirely sure. So I’m going to write one more rather harder test and check more values. Let’s draw a square. My first cut at the test is this:
_:test("square", function()
local square = {
{s=3, x = 1, y = 1}, -- /
{s=3, x = 0, y = -1}, -- |
{s=3, x = -1, y = 0}, -- _
{s=3, x = 0, y = 1}, -- |
{s=3, x = 1, y = 0} -- _
}
local big = convertTable(square)
_:expect(big[1]).is(vec2(2,2))
end)
And it fails, because big[1]
isn’t a vector, it’s a table. The offender is:
function cumulate(prev, data)
return {x = prev.x + data.x, y = prev.y + data.y}
end
I never converted that to vec2
, and my previous test never checked the type, it just pulled out x and y. So let’s fix that:
function cumulate(prev, data)
return data + prev
end
And all the tests run so far. But I’m going to add more checks to the square, just to be sure. It should go 2,2, 2,-2, -2,-2, -2,2, 2,2, I reckon. Let’s see if it does … well, in fact, it doesn’t. as any fool but me could see, this square is going to have its lower left corner at 0,0 and be 2x2 from there. So it should go 2,2, 2,0, 0,0, 0,2, 2,2. I’ll fix the test:
_:test("square", function()
local square = {
{s=3, x = 1, y = 1}, -- /
{s=3, x = 0, y = -1}, -- |
{s=3, x = -1, y = 0}, -- _
{s=3, x = 0, y = 1}, -- |
{s=3, x = 1, y = 0} -- _
}
local big = convertTable(square)
_:expect(big[1]).is(vec2(2,2))
_:expect(big[2]).is(vec2(2,0))
_:expect(big[3]).is(vec2(0,0))
_:expect(big[4]).is(vec2(0,2))
_:expect(big[5]).is(vec2(2,2))
end)
OK, not what I had in mind, but a perfectly good test. I’m really quite confident in my converter now, and the mass of mistakes I’ve made make me feel good about having broken down and done the tests.
Could I have debugged until done? Surely. Would I have felt this good about it all? Probably not.
Now let’s go back to main and use these handy functions. They’re global in the test tab, so I can just call them as needed. My purpose,of course, is to produce source code that I can paste into the Asteroids program. Here goes …
OK, this took a few minutes of fiddling to get the output format to look about right in the Codea console, and I decided to draw the asteroid as well:
-- ConvertRocks
function setup()
nl = "\n"
local output = ""
rr1 = convertTable(R1)
output = output .. printTable("RR1", rr1, output)
print(output)
end
function printTable(name, tab)
local o = ""
o = o .. name.." = {" .. nl
for i,vec in ipairs(tab) do
local comma = i~=#tab and "," or ""
o = o .. " vec2"..tostring(vec)..comma .. nl
end
return o .."}" .. nl
end
function draw()
pushMatrix()
pushStyle()
translate(WIDTH/2, HEIGHT/2)
stroke(255)
scale(10)
strokeWidth(1/10)
local p = vec2(0,0)
for i, n in ipairs(rr1) do
line(p.x,p.y, n.x, n.y)
p = n
end
popStyle()
popMatrix()
end
When we run that, here’s what we see:
There’s a nicely formatted print over there in the console, and the asteroid is drawn just as I expected. Notice the line from zero out to the border. I did expect that to be drawn, since that’s how they start. In our real drawing, we’ll need to accommodate that by drawing the first line with no color, just like the DVG does it.
My plan now is to store the output in a separate tab in this program, which I can copy over to the main Asteroids program at my leisure:
That’s easily done with saveProjectTab
:
function setup()
nl = "\n"
local output = ""
rr1 = convertTable(R1)
output = output .. printTable("RR1", rr1, output)
print(output)
saveProjectTab("Converted", output)
end
And the result is a new tab in this program, with the RR1 definition in it:
This is close enough, so I’ll add a couple of extra newlines, convert all four asteroids, and display them all. That should provide me with a slightly better drawing function as well.
The final Main looks like this:
-- ConvertRocks
function setup()
nl = "\n"
local output = ""
rr1 = convertTable(R1)
output = output .. printTable("RR1", rr1, output)
rr2 = convertTable(R2)
output = output .. printTable("RR2", rr2, output)
rr3 = convertTable(R3)
output = output .. printTable("RR3", rr3, output)
rr4 = convertTable(R4)
output = output .. printTable("RR4", rr4, output)
print(output)
saveProjectTab("Converted", output)
end
function printTable(name, tab)
local o = ""
o = o .. name.." = {" .. nl
for i,vec in ipairs(tab) do
local comma = i~=#tab and "," or ""
o = o .. " vec2"..tostring(vec)..comma .. nl
end
return o .."}" .. nl .. nl
end
function draw()
local w = WIDTH/2
local h = HEIGHT/2
drawTable(w,h, rr1)
drawTable(w+100,h, rr2)
drawTable(w+200,h, rr3)
drawTable(w+300,h, rr4)
end
function drawTable(x,y, tab)
pushMatrix()
pushStyle()
translate(x,y)
stroke(0)
scale(10)
strokeWidth(1/10)
local p = vec2(0,0)
for i, n in ipairs(tab) do
line(p.x,p.y, n.x, n.y)
stroke(255)
p = n
end
popStyle()
popMatrix()
end
And, with that tweak to stroke
, the output looks like this, without the lines from center:
And the Converted tab has all the tables in it:
This has been a good morning’s work in my view. I well deserve the Diet Coke I just cracked open.
Summing Up
Today’s big lesson for me was to test my darn code. My first fumbling attempt to convert the data told me I was making mistakes, and then even during the nice and safe TDD process, I made more. I think I told you about most of them.
And I went further with the tests than I had anticipated, to my benefit, and then went further with the program itself than I had anticipated, because things were going smoothly and I was able to press on with confidence.
The formatting code is a bit nasty but it is a one-off. At least that’s my story for now, and for now I’ll stick to it. I don’t plan to convert many more tables … although, come to think of it … there are those saucers …
But that’s for another day. For today, we’ve made a nice step forward. Here are all the tabs, including the converted one:
-- ConvertRocks
function setup()
nl = "\n"
local output = ""
rr1 = convertTable(R1)
output = output .. printTable("RR1", rr1, output)
rr2 = convertTable(R2)
output = output .. printTable("RR2", rr2, output)
rr3 = convertTable(R3)
output = output .. printTable("RR3", rr3, output)
rr4 = convertTable(R4)
output = output .. printTable("RR4", rr4, output)
print(output)
saveProjectTab("Converted", output)
end
function printTable(name, tab)
local o = ""
o = o .. name.." = {" .. nl
for i,vec in ipairs(tab) do
local comma = i~=#tab and "," or ""
o = o .. " vec2"..tostring(vec)..comma .. nl
end
return o .."}" .. nl .. nl
end
function draw()
local w = WIDTH/2
local h = HEIGHT/2
drawTable(w,h, rr1)
drawTable(w+100,h, rr2)
drawTable(w+200,h, rr3)
drawTable(w+300,h, rr4)
end
function drawTable(x,y, tab)
pushMatrix()
pushStyle()
translate(x,y)
stroke(0)
scale(10)
strokeWidth(1/10)
local p = vec2(0,0)
for i, n in ipairs(tab) do
line(p.x,p.y, n.x, n.y)
stroke(255)
p = n
end
popStyle()
popMatrix()
end
-- Rock Pattern 1
R1 = {
{s=3, x=0, y=1},
{s=3, x=1, y=1},
{s=3, x=1, y=-1},
{s=2, x=-1, y=-2},
{s=2, x=1, y=-2},
{s=2, x=-3, y=-2},
{s=2, x=-3, y=0},
{s=3, x=-1, y=1},
{s=3, x=0, y=2},
{s=3, x=1, y=1},
{s=3, x=1, y=-1}
}
--
-- Rock Pattern 2
R2 = {
{s=2, x=2, y=1},
{s=2, x=2, y=1},
{s=3, x=-1, y=1},
{s=2, x=-2, y=-1},
{s=2, x=-2, y=1},
{s=3, x=-1, y=-1},
{s=2, x=1, y=-2},
{s=2, x=-1, y=-2},
{s=3, x=1, y=-1},
{s=2, x=1, y=1},
{s=2, x=3, y=-1},
{s=2, x=2, y=3},
{s=3, x=-1, y=1}
}
--
-- Rock Pattern 3
R3 = {
{s=3, x=-1, y=0},
{s=2, x=-2, y=-1},
{s=2, x=2, y=-3},
{s=2, x=2, y=3},
{s=2, x=0, y=-3},
{s=3, x=1, y=0},
{s=2, x=2, y=3},
{s=3, x=0, y=1},
{s=2, x=-2, y=3},
{s=2, x=-3, y=0},
{s=2, x=-3, y=-3},
{s=2, x=2, y=-1}
}
--
-- Rock Pattern 4
R4 = {
{s=2, x=1, y=0},
{s=2, x=3, y=1},
{s=2, x=0, y=1},
{s=2, x=-3, y=2},
{s=2, x=-3, y=0},
{s=2, x=1, y=-2},
{s=2, x=-3, y=0},
{s=2, x=0, y=-3},
{s=2, x=2, y=-3},
{s=2, x=3, y=1},
{s=2, x=1, y=-1},
{s=3, x=1, y=1},
{s=2, x=-3, y=2}
}
function testCodeaUnitFunctionality()
CodeaUnit.detailed = true
_:describe("CodeaUnit Test Suite", function()
_:before(function()
-- Some setup
end)
_:after(function()
-- Some teardown
end)
_:test("cumulate unscaled", function()
local prev = {x=2,y=3}
local data = {s=1, x=2, y=4}
local next = cumulate(prev,data)
_:expect(next.x).is(4)
_:expect(next.y).is(7)
end)
_:test("scale 2 returns unchanged", function()
local input = {s=2,x=1,y=2}
local new = scaled(input)
_:expect(new.x).is(1)
_:expect(new.y).is(2)
end)
_:test("scale 3 returns double", function()
local input = {s=3,x=1,y=2}
local new = scaled(input)
_:expect(new.x).is(2)
_:expect(new.y).is(4)
end)
_:test("unscaled cumulated", function()
local prev = vec2(2,3)
local data = {s=1, x=2, y=4}
local next = cumulateScaled(prev, data)
_:expect(next.x).is(4)
_:expect(next.y).is(7)
end)
_:test("scaled cumulated", function()
local prev = vec2(2,3)
local data = {s=3, x=2, y=4}
local next = cumulateScaled(prev, data)
_:expect(next.x).is(6)
_:expect(next.y).is(11)
end)
_:test("do a table", function()
local intab = {
{s=2, x=1, y=2},
{s=3, x=3, y=4},
}
local outab = convertTable(intab)
_:expect(#outab).is(2)
local last = outab[2]
_:expect(last.x).is(7)
_:expect(last.y).is(10)
end)
_:test("square", function()
local square = {
{s=3, x = 1, y = 1}, -- /
{s=3, x = 0, y = -1}, -- |
{s=3, x = -1, y = 0}, -- _
{s=3, x = 0, y = 1}, -- |
{s=3, x = 1, y = 0} -- _
}
local big = convertTable(square)
_:expect(big[1]).is(vec2(2,2))
_:expect(big[2]).is(vec2(2,0))
_:expect(big[3]).is(vec2(0,0))
_:expect(big[4]).is(vec2(0,2))
_:expect(big[5]).is(vec2(2,2))
end)
end)
end
function convertTable(tab)
local result = {}
local prev = vec2(0,0)
for i, t in ipairs(tab) do
local next = cumulateScaled(prev, t)
table.insert(result, next)
prev = next
end
return result
end
function cumulateScaled(prev, data)
return cumulate(prev, scaled(data))
end
function cumulate(prev, data)
return data + prev
end
function scaled(input)
local mul = (input.s~=3) and 1 or 2
return vec2(input.x, input.y)*mul
end
RR1 = {
vec2(0.000000, 2.000000),
vec2(2.000000, 4.000000),
vec2(4.000000, 2.000000),
vec2(3.000000, 0.000000),
vec2(4.000000, -2.000000),
vec2(1.000000, -4.000000),
vec2(-2.000000, -4.000000),
vec2(-4.000000, -2.000000),
vec2(-4.000000, 2.000000),
vec2(-2.000000, 4.000000),
vec2(0.000000, 2.000000)
}
RR2 = {
vec2(2.000000, 1.000000),
vec2(4.000000, 2.000000),
vec2(2.000000, 4.000000),
vec2(0.000000, 3.000000),
vec2(-2.000000, 4.000000),
vec2(-4.000000, 2.000000),
vec2(-3.000000, 0.000000),
vec2(-4.000000, -2.000000),
vec2(-2.000000, -4.000000),
vec2(-1.000000, -3.000000),
vec2(2.000000, -4.000000),
vec2(4.000000, -1.000000),
vec2(2.000000, 1.000000)
}
RR3 = {
vec2(-2.000000, 0.000000),
vec2(-4.000000, -1.000000),
vec2(-2.000000, -4.000000),
vec2(0.000000, -1.000000),
vec2(0.000000, -4.000000),
vec2(2.000000, -4.000000),
vec2(4.000000, -1.000000),
vec2(4.000000, 1.000000),
vec2(2.000000, 4.000000),
vec2(-1.000000, 4.000000),
vec2(-4.000000, 1.000000),
vec2(-2.000000, 0.000000)
}
RR4 = {
vec2(1.000000, 0.000000),
vec2(4.000000, 1.000000),
vec2(4.000000, 2.000000),
vec2(1.000000, 4.000000),
vec2(-2.000000, 4.000000),
vec2(-1.000000, 2.000000),
vec2(-4.000000, 2.000000),
vec2(-4.000000, -1.000000),
vec2(-2.000000, -4.000000),
vec2(1.000000, -3.000000),
vec2(2.000000, -4.000000),
vec2(4.000000, -2.000000),
vec2(1.000000, 0.000000)
}