Asteroids 2: Two square asteroids
Today I decided to draw two square asteroids at random angles. All’s well that ends well, but I did get confused.
We closed last time with these questions written down:
- Suppose we wanted two asteroids, or many of them. What are some ways we might do that?
- Are there better ways to handle the boundary checking?
- Game asteroids are odd polygons, and there are a few different shapes. How will we handle that?
- Game asteroids split when shot, making a number of smaller asteroids. The smaller ones use similar shapes to the original ones but smaller. How might we handle that?
Probably there were more questions in your mind, and surely there were in mine. I also mentioned that I was somewhat inclined to keep going forward in this rather procedural style, and I’ve decided to do that for a while. Here’s why:
I would usually move very quickly to classes. I think in object-oriented terms, and I’m sure I’ll wind up there, so I go there very early. Arguably I should apply the YAGNI: You’re Not Gonna Need It principle, which tells us to do things when we actually need them, not when we think we are going to need them.
Objects, formally speaking, hold data which is pertinent to them, and they contain procedures for working on that data. To me, an object serves another purpose: it provides a vessel for partitioning my thoughts.
Programs quickly get too large for me to remember everything I’d like to, and when I use classes and objects, they provide a convenient hook for remembering where to look for things. Can the ship explode? Look in the Ship class for an explode function.
Objects have very low cost for my mind: I’m used to them. And they have rather low cost to the implementation as well. There may be a bit more overhead to an object method dispatch, but it’s small enough that you’ll have a hard time measuring it.
Other code builders will not have this same bias that I have. In the spirit of what seems to be simplicity, let’s proceed without objects for a while.
Design Thoughts
Remember that objects encapsulate the data that pertains to a thing, and the behavior of the thing. Right now, we hardly have any behavior to worry about, and just a tiny bit of data. So we’ll see what we have that might be a bit lighter in weight than an object. In Lua, that’s a table.
Lua has very few data types built in. It has eight data types: nil, boolean, number, string, function, userdata, thread, and table. Of these, most Codea programs will never see userdata or thread. The only structured data type in Lua is table.
A table is a key-value structure. Typically a table will have integer number keys, in order, so that it behaves as an array, or string keys, so that it behaves like a hash table. You can use any unique value as a key, but using other than integer numbers or strings is rare.
Our asteroids need to know their X and Y coordinates. They need to know how much to increment X and Y on every draw cycle: we’re currently incrementing X by 1 and Y by 2. We know we need a few different shapes, and a few different sizes. And … though I don’t think I’ve mentioned it, surely we’ll want to have our asteroids rotate slowly as they float along in space.
So asteroids need to know a few things. We’ll start by storing those thing in a table for each asteroid. And we’ll surely store those tables in another so that we can process all of them.
Graphical Cleanup
One more thing. Our current drawing code is very rudimentary. There are some bits of cleanup that we’ll do, and I’ll call them out because they are useful to know and quite generally useful.
Let’s get started.
Two Square Asteroids
I’ve decided that we’ll move from where we are now, to having two square asteroids on the screen at once. That should rive out some interesting issues and cause us to expand and improve the code.
I’m wondering about tests. So far, I’ve not felt the need to write micro tests for anything. We’re ready to do it but I’ve seen nothing that seems to need more than visual checking. I’ll try to stay alert for things to test.
Our starting code looks like this:
-- Asteroids
-- RJ 20200511
function setup()
print("Hello Asteroids!")
X = 400
Y = 500
end
function draw()
background(40, 40, 50)
strokeWidth(1)
stroke(255)
fill(40, 40,50)
ellipseMode(CENTER)
ellipse(X,Y, 120)
X = X + 1
if X > WIDTH then X = X - WIDTH end
if X < 0 then X = X + WIDTH end
Y = Y + 2
if Y > HEIGHT then Y = Y - HEIGHT end
if Y < 0 then Y = Y + HEIGHT end
end
We want a square asteroid rather than round:
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(X,Y, 120)
X = X + 1
if X > WIDTH then X = X - WIDTH end
if X < 0 then X = X + WIDTH end
Y = Y + 2
if Y > HEIGHT then Y = Y - HEIGHT end
if Y < 0 then Y = Y + HEIGHT end
end
Note that I had to change the strokeWidth
from 1 to 2. I did that because the rectangle was only barely visible. I guess that the circle drawing by its nature puts more pixels close together. Other than that I just changed to “rect” from “ellipse”. Now we have a square flying along:
Now what? I’m going to want to create more than one of these guys, and each one will have its own X and Y and step. Now I think what I’d like to do is specify the angle it flies at, rather than its X and Y. Right now, I think it’s flying at a velocity of sqrt(3) or about 1.7. I think I’ll go for a velocity of 1.5. It goes like this:
function setup()
print("Hello Asteroids!")
Pos = vec2(400,500)
Angle = 60
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(Pos.x, Pos.y, 120)
Pos.x = Pos.x + 1
if Pos.x > WIDTH then Pos.x = Pos.x - WIDTH end
if Pos.x < 0 then Pos.x = Pos.x + WIDTH end
Pos.y = Pos.y + 2
if Pos.y > HEIGHT then Pos.y = Pos.y - HEIGHT end
if Pos.y < 0 then Pos.y = Pos.y + HEIGHT end
end
Here I decided to make the separate X and Y into a t2d vector, vec2
. Lest you suspect that vec2
is another Lua type, well, it really isn’t: it’s just a table that by convention contains two elements x and y. The notation Pos.x
is equivalent to Pos["x"]
and just fetches the x value from the table.
I thought I’d be glad to have the coordinates in a vector but so far it has just made things messier. I still think it’s right, but we’ll soon see. So far I’ve not used the Angle
or Vel
. Just this much change seemed like enough, so I sliced here to show you what was up. Now to add in movement in another direction:
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(Pos.x, Pos.y, 120)
local step = vec2(1,0):rotate(math.rad(Angle))
Pos = Pos + step
Pos.x = Pos.x + 1
if Pos.x > WIDTH then Pos.x = Pos.x - WIDTH end
if Pos.x < 0 then Pos.x = Pos.x + WIDTH end
Pos.y = Pos.y + 2
if Pos.y > HEIGHT then Pos.y = Pos.y - HEIGHT end
if Pos.y < 0 then Pos.y = Pos.y + HEIGHT end
end
Here I computed step
by rotating the unit horizontal vector by Angle
and then added step to Pos to move my square. I see that I forgot to use Vel
. I should have said this:
local step = vec2(Vel,0):rotate(math.rad(Angle))
That, by the way, is a “thing to know”. To compute a vector of length L at an angle of Angle in degrees:
vec2(L,0):rotate(math.rad(Angle))
My angle is currently in degrees. Since we won’t be doing much with it, let’s convert it to radians to avoid the call. I think I’ll make it random while I’m at it.
function setup()
print("Hello Asteroids!")
Pos = vec2(400,500)
Angle = math.random()*2*math.pi
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(Pos.x, Pos.y, 120)
local step = vec2(Vel,0):rotate(Angle)
Pos = Pos + step
Pos.x = Pos.x + 1
if Pos.x > WIDTH then Pos.x = Pos.x - WIDTH end
if Pos.x < 0 then Pos.x = Pos.x + WIDTH end
Pos.y = Pos.y + 2
if Pos.y > HEIGHT then Pos.y = Pos.y - HEIGHT end
if Pos.y < 0 then Pos.y = Pos.y + HEIGHT end
end
So I made Angle random between 0 and 2 PI, and removed the math.rad
from the rotate. However, I’m not seeing what I expected to see.
I would expect my square to move in some random direction at a roughly constant speed. That’s not the case in two regards. First, the speed is visibly not constant. That means that the size of step
isn’t constant, which I expect it to be. (Or something else is happening.)
Second, the rectangle never moves downward, either to left or right. It “should” go up or down equally, I’d have thought.
This might be time for a unit test, or at least for a bit of printing.
First, I want to be sure that I’m getting values between 0 and 2 PI.
_:test("Random", function()
local min = 100
local max = 0
for i = 0,1000 do
local rand = math.random()*2*math.pi
if rand < min then min = rand end
if rand > max then max = rand end
end
_:expect(min < 0.01).is(true)
_:expect(max > 6.2).is(true)
end)
CodeaUnit doesn’t support is_greater and such, so for now I did this. This test passes. So now I’m wondering about the length of the rotated vector. Again, unfortunately, we need to check a lot of them.
_:test("Rotated Length", function()
for i = 0, 1000 do
local rand = math.random()*2*math.pi
local v = vec2(1.5,0):rotate(rand)
local d = v:len()
_:expect(d > 1.495).is(true)
_:expect(d < 1.505).is(true)
end
end)
This passes, telling me my lengths are all OK. I still don’t understand why the velocity apparently changes, nor why the square never goes down.
Let’s check those rotated vectors further. I think I’ll check some specific values.
_:test("Some rotates go down", function()
local angle = math.rad(-45)
local v = vec2(1,0):rotate(angle)
local rvx = v.x*1000//1
local rvy = v.y*1000//1
_:expect(rvx).is(707)
_:expect(rvy).is(-708)
end)
I had to jump though some hoops because CodeaUnit doesn’t help much on floats. Note that the negative value rounded to .708, but that’s fine by me.
So this tells me that the math works, the step vector should always have a size of 1.5 and step.y should sometimes be negative. So what’s going on?
Now you could argue that I’ve been doing something pretty silly, essentially testing that Lua works. But that’s not quite what I was doing: I was checking that my understanding of how to use Lua was accurate. And it seems that it is. So the problem is somewhere else. But where?
Back to stare at the code:
function setup()
print("Hello Asteroids!")
Pos = vec2(400,500)
Angle = math.random()*2*math.pi
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(Pos.x, Pos.y, 120)
local step = vec2(Vel,0):rotate(Angle)
Pos = Pos + step
Pos.x = Pos.x + 1
if Pos.x > WIDTH then Pos.x = Pos.x - WIDTH end
if Pos.x < 0 then Pos.x = Pos.x + WIDTH end
Pos.y = Pos.y + 2
if Pos.y > HEIGHT then Pos.y = Pos.y - HEIGHT end
if Pos.y < 0 then Pos.y = Pos.y + HEIGHT end
end
I sure don’t see it. So I think I want to start asking some questions, like whether Angle is ever greater than PI (which would be the downward ones) and whether step.y is ever negative.
Fortunately, I got up for a break and had a less stupid idea. Why don’t I set an Angle that should go down and see if it does?
Well! I set it to 7*PI/8 and the square goes up and to the left. I expected down and to the right, because I expect Angle 0 to go straight left. I’ll try that …
Arrgh! Did you see the bug? Why didn’t you tell me??? I put in the adjustment of Pos
by step
, but I left in the + 1 and +2 for x and y.
I bet you saw that. This is why pair programming is so useful. Even the cat would have seen that but she probably wouldn’t have told me.
Anyway … whew … we’re here now and the square moves in a different direction and apparently constant speed whenever I restart the program.
function setup()
print("Hello Asteroids!")
Pos = vec2(400,500)
Angle = math.random()*2*math.pi
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(Pos.x, Pos.y, 120)
local step = vec2(Vel,0):rotate(Angle)
Pos = Pos + step
if Pos.x > WIDTH then Pos.x = Pos.x - WIDTH end
if Pos.x < 0 then Pos.x = Pos.x + WIDTH end
if Pos.y > HEIGHT then Pos.y = Pos.y - HEIGHT end
if Pos.y < 0 then Pos.y = Pos.y + HEIGHT end
end
With your kind permission, I’m going to commit this code: “rectangle starts moving at random angle”.
Now if I were a different kind of person, you’d never know that I went down that rathole. I’d have written this to move smoothly from one thing to another, as if I were some god of programming. But the truth is, while I am fairly good at this stuff after half a century, sometimes something escapes my eye. I want you to see that, because I want you to understand that you don’t have to start out with perfect code, but you can get to code that is as perfect as you choose, over time.
Anyway, where were we? We want two squares, moving at different angles, not just one. To me that means that we want each square represented in a table (I really want to do a class but for now, I’ll hold back), and we want a table of those tables to loop over in our draw function.
First I’ll create the first asteroid table and use it:
function setup()
print("Hello Asteroids!")
asteroid = {}
asteroid.pos = vec2(400,500)
asteroid.angle = math.random()*2*math.pi
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
rect(asteroid.pos.x, asteroid.pos.y, 120)
local step = vec2(Vel,0):rotate(asteroid.angle)
asteroid.pos = asteroid.pos + step
if asteroid.pos.x > WIDTH then asteroid.pos.x = asteroid.pos.x - WIDTH end
if asteroid.pos.x < 0 then asteroid.pos.x = asteroid.pos.x + WIDTH end
if asteroid.pos.y > HEIGHT then asteroid.pos.y = asteroid.pos.y - HEIGHT end
if asteroid.pos.y < 0 then asteroid.pos.y = asteroid.pos.y + HEIGHT end
end
That works. The edit was pretty much just a replace. Now let’s make a table of asteroids and put our guy into it and loop. Maybe I’ll even get brave and put two in there.
-- Asteroids
-- RJ 20200511
function setup()
print("Hello Asteroids!")
Asteroids = {}
local asteroid = {}
asteroid.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
asteroid.angle = math.random()*2*math.pi
table.insert(Asteroids,asteroid)
asteroid = {}
asteroid.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
asteroid.angle = math.random()*2*math.pi
table.insert(Asteroids,asteroid)
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
for i,asteroid in ipairs(Asteroids) do
rect(asteroid.pos.x, asteroid.pos.y, 120)
local step = vec2(Vel,0):rotate(asteroid.angle)
asteroid.pos = asteroid.pos + step
if asteroid.pos.x > WIDTH then asteroid.pos.x = asteroid.pos.x - WIDTH end
if asteroid.pos.x < 0 then asteroid.pos.x = asteroid.pos.x + WIDTH end
if asteroid.pos.y > HEIGHT then asteroid.pos.y = asteroid.pos.y - HEIGHT end
if asteroid.pos.y < 0 then asteroid.pos.y = asteroid.pos.y + HEIGHT end
end
end
And it works:
Commit: two moving square asteroids.
Summing Up
This is a good place to stop and reflect.
First, if I weren’t tired, I’d say we should refactor this code. The asteroid creation could at least be a separate function called multiple times. The asteroid movement should be a separate function called from the loop, and the normalization of the coordinates to be inside the screen bounds should be improved somehow. Offhand, I don’t know a way that I like, but maybe something like a new function “force v between low and high” would be helpful.
I am tired, though, and I think I won’t refactor just now.
What else? My instinct failed me on the squares always moving upward. I totally missed that while I had correctly added in the adjustment by step
, I didn’t remove the old literal stepping. That sent me down a rathole of testing assumptions that were further away from the code than the problem was.
It happens, and so I’m glad to have had the occasion to show you that it happens. You’ll note that I recognize is was a dumb mistake but I’m not beating myself up about it. People make mistakes. The trick is to notice them and figure out what they are as soon as possible.
The micro tests I wrote were interesting but not really useful. Maybe I’ll set them to ignore or something later. And it’s worth at least thinking about whether we should extend CodeaUnit.
All that aside, we have what should turn into a tight loop drawing asteroids, and it shouldn’t be too ugly. I almost can’t resist doing the improvements right now.
But no. It’s time for a break. I’m tired and that doesn’t make for good code. Maybe I’ll come back later today. More likely, tomorrow.
For now, thanks to both of you for reading, and I hope you try Codea for yourself. If you do, I hope to see what you build.
Here’s the code:
-- Asteroids
-- RJ 20200511
function setup()
print("Hello Asteroids!")
Asteroids = {}
local asteroid = {}
asteroid.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
asteroid.angle = math.random()*2*math.pi
table.insert(Asteroids,asteroid)
asteroid = {}
asteroid.pos = vec2(math.random(WIDTH), math.random(HEIGHT))
asteroid.angle = math.random()*2*math.pi
table.insert(Asteroids,asteroid)
Vel = 1.5
end
function draw()
background(40, 40, 50)
stroke(255)
fill(40, 40,50)
strokeWidth(2)
rectMode(CENTER)
for i,asteroid in ipairs(Asteroids) do
rect(asteroid.pos.x, asteroid.pos.y, 120)
local step = vec2(Vel,0):rotate(asteroid.angle)
asteroid.pos = asteroid.pos + step
if asteroid.pos.x > WIDTH then asteroid.pos.x = asteroid.pos.x - WIDTH end
if asteroid.pos.x < 0 then asteroid.pos.x = asteroid.pos.x + WIDTH end
if asteroid.pos.y > HEIGHT then asteroid.pos.y = asteroid.pos.y - HEIGHT end
if asteroid.pos.y < 0 then asteroid.pos.y = asteroid.pos.y + HEIGHT end
end
end
-- TestAsteroids
-- RJ 20200511
function testAsteroids()
CodeaUnit.detailed = true
_:describe("Asteroids First Tests", function()
_:before(function()
-- Some setup
end)
_:after(function()
-- Some teardown
end)
_:test("Hookup", function()
_:expect( 2+1 ).is(3)
end)
_:test("Random", function()
local min = 100
local max = 0
for i = 0,1000 do
local rand = math.random()*2*math.pi
if rand < min then min = rand end
if rand > max then max = rand end
end
_:expect(min < 0.01).is(true)
_:expect(max > 6.2).is(true)
end)
_:test("Rotated Length", function()
for i = 0, 1000 do
local rand = math.random()*2*math.pi
local v = vec2(1.5,0):rotate(rand)
local d = v:len()
_:expect(d > 1.495).is(true)
_:expect(d < 1.505).is(true)
end
end)
_:test("Some rotates go down", function()
local angle = math.rad(-45)
local v = vec2(1,0):rotate(angle)
local rvx = v.x*1000//1
local rvy = v.y*1000//1
_:expect(rvx).is(707)
_:expect(rvy).is(-708)
end)
end)
end