Space Invaders 7: I'm goin' in!
Let’s see what happens if we try to refactor our little spike into something reasonable.
OK, I’m freshly showered and shaved, and back from a nearby Starbucks with an iced chai. My hair is mussed from the convertible ride and I am ready to rumble. My plan, just for fun, is to see how easy or hard it is to turn the current little spike into decent code. Things like this are about as bad as my code ever gets without trying to write bad code, so this should be a decent experiment, albeit small.
Here’s the whole thing:
-- invadersSpike
function setup()
ranks = {}
for rank = 0,4 do
local rankTable = {}
rankTable.rank = rank
rankTable.invaders={}
rankTable.offset = vec2(0,0)
table.insert(ranks,rankTable)
for col = 0,10 do
table.insert(rankTable.invaders, {p=vec2(col*16,rank*16)})
end
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
function draw()
update(ElapsedTime)
background(40, 40, 50)
rectMode(CENTER)
noFill()
stroke(255)
strokeWidth(2)
rect(WIDTH/2, HEIGHT/2, 224*4, 256*4)
spriteMode(CORNER)
text(DeltaTime, 100,100)
translate(WIDTH/2-224*2, HEIGHT/2)
drawInvaders()
end
function drawInvaders()
scale(4)
for r,rankEntry in ipairs(ranks) do
for i,invader in ipairs(rankEntry.invaders) do
local pos = invader.p + rankEntry.offset
if updateStep.x > 0 and pos.x > 224 - 16 then
reverse = true
elseif updateStep.x < 0 and pos.x < 0 then
reverse = true
end
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
function update(elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
if updateRow == 0 and reverse then
updateStep = - updateStep - vec2(0,2)
reverse = false
end
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
if updateRow == 0 then updateStep.y = 0 end
end
end
You see how it is, even in a spike, I can’t resist breaking things out, like the update function and the drawInvaders function. I do that habitually, because it helps me when the code expresses my thoughts, and when I write something like that out longhand, and discover a bit later what’s going on, I often break it out. I find it a useful habit to have developed.
But now let’s see what we can really see in this thing. Here’s setup on its own:
function setup()
ranks = {}
for rank = 0,4 do
local rankTable = {}
rankTable.rank = rank
rankTable.invaders={}
rankTable.offset = vec2(0,0)
table.insert(ranks,rankTable)
for col = 0,10 do
table.insert(rankTable.invaders, {p=vec2(col*16,rank*16)})
end
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
It starts out by building a table called ranks, and in fact each row of the table represents a rank of invaders. Each table entry has the rank
(0-4), an offset
which is always (0,0), which doesn’t seem very useful, and a table called invaders
, containing 11 tables, each of which represents an invader. Those invader tables each have one member, p
, which is the invader’s column number in x and row number in y, both multiplied by 16, the width of an invader image.
A glance at the drawInvaders
code tells us that the rank offset
is added to the column/row info of each invader to draw her, and in update
we see that we step the rank offsets left and right and down as need be, to move the rank. Each rank has its own offset, because they step downward separately to give that neat rippling effect.
So it seems like rank is a good concept to break out, and perhaps also invader. Both are already represented by tables of data, but since they aren’t objects, their behavior is threaded around in the general procedures in Main. We would hope that making them full-fledged objects would improve the overall cohesion of the code.
Let’s begin with a Rank
class. We do this by rote, just giving it the same elements as the table we’re now using:
Rank = class()
function Rank:init(rank, offset, invaders)
self.rank = rank
self.offset = offset
self.invaders = invaders
end
function Rank:draw()
end
Now we plug it into setup:
function setup()
ranks = {}
for rank = 0,4 do
local rankItem = Rank(rank,vec2(0,0),{})
table.insert(ranks,rankItem)
for col = 0,10 do
table.insert(rankItem.invaders, {p=vec2(col*16,rank*16)})
end
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
And it’s all good. Everything works, which shouldn’t be a surprise: there’s really no difference between a table and an object unless and until we put behavior on the object.
We notice that the second and third inputs to Rank
are constant, so we move them inside:
function Rank:init(rank, offset, invaders)
self.rank = rank
self.offset = vec2(0,0)
self.invaders = {}
end
function setup()
ranks = {}
for rank = 0,4 do
local rankItem = Rank(rank)
table.insert(ranks,rankItem)
for col = 0,10 do
table.insert(rankItem.invaders, {p=vec2(col*16,rank*16)})
end
end
...
It’s a bit weird for our init to be pushing invader table entries into the rank object’s table. Let’s make him do that. He has enough information to initialize the invader table himself:
function Rank:init(rank, offset, invaders)
self.rank = rank
self.offset = vec2(0,0)
self.invaders = {}
self:initInvaders()
end
function Rank:initInvaders()
for col = 0,10 do
table.insert(self.invaders, {p=vec2(col*16,self.rank*16)})
end
end
That means we can delete that loop from the setup:
function setup()
ranks = {}
for rank = 0,4 do
local rankItem = Rank(rank)
table.insert(ranks,rankItem)
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
Now the two lines in the loop can be turned into one:
function setup()
ranks = {}
for rank = 0,4 do
table.insert(ranks,Rank(rank))
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
Everything still works fine. We could break out that little patch of code at the top. What if we made a class method on Rank to produce a rank table?
function Rank:createRanks()
local ranks = {}
for rank = 0,4 do
table.insert(ranks,Rank(rank))
end
return ranks
end
function setup()
ranks = Rank:createRanks()
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
...
We could even do this, which is arguably more clear:
function Rank:createRanks()
return { Rank(0), Rank(1), Rank(2), Rank(3), Rank(4) }
end
I rather like that.
I’d commit this code if my code manager were working. As it is, I’ll just nervously proceed, remembering that if the going gets tough, the source is all here in the articles. Arrgh.
What about drawing?
Drawing
The draw code looks like this:
function drawInvaders()
scale(4)
for r,rankEntry in ipairs(ranks) do
for i,invader in ipairs(rankEntry.invaders) do
local pos = invader.p + rankEntry.offset
if updateStep.x > 0 and pos.x > 224 - 16 then
reverse = true
elseif updateStep.x < 0 and pos.x < 0 then
reverse = true
end
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
Now what I’d really like to have right now is a smart table for the ranks, with a draw method on it. But for now, let’s just move this functionality over to the Rank object, who at least has the keys to the kingdom.
function Rank:drawInvaders(ranks)
scale(4)
for r,rankEntry in ipairs(ranks) do
for i,invader in ipairs(rankEntry.invaders) do
local pos = invader.p + rankEntry.offset
if updateStep.x > 0 and pos.x > 224 - 16 then
reverse = true
elseif updateStep.x < 0 and pos.x < 0 then
reverse = true
end
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
This is called in Main draw:
function draw()
update(ElapsedTime)
background(40, 40, 50)
rectMode(CENTER)
noFill()
stroke(255)
strokeWidth(2)
rect(WIDTH/2, HEIGHT/2, 224*4, 256*4)
spriteMode(CORNER)
text(DeltaTime, 100,100)
translate(WIDTH/2-224*2, HEIGHT/2)
Rank:drawInvaders(ranks)
end
We note that in setup and draw, there’s no use for the ranks table, which could be internal to Rank. But there’s update:
function update(elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
if updateRow == 0 and reverse then
updateStep = - updateStep - vec2(0,2)
reverse = false
end
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
if updateRow == 0 then updateStep.y = 0 end
end
end
This too could be put into Rank. Its timing stuff is about when to draw a Rank, which is the rank’s business, as is the reversing logic. We’ll move it wholesale, passing in the ranks table to make it work:
function Rank:update(ranks,elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
if updateRow == 0 and reverse then
updateStep = - updateStep - vec2(0,2)
reverse = false
end
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
if updateRow == 0 then updateStep.y = 0 end
end
end
function draw()
Rank:update(ranks, ElapsedTime)
background(40, 40, 50)
rectMode(CENTER)
noFill()
stroke(255)
strokeWidth(2)
rect(WIDTH/2, HEIGHT/2, 224*4, 256*4)
spriteMode(CORNER)
text(DeltaTime, 100,100)
translate(WIDTH/2-224*2, HEIGHT/2)
Rank:drawInvaders(ranks)
end
Now there is no reason for Main to know about the ranks table at all. We’ll make them local to Rank:
Rank = class()
local Ranks;
function Rank:createRanks()
Ranks = { Rank(0), Rank(1), Rank(2), Rank(3), Rank(4) }
end
I capitalized Ranks for two reasons. First of all, it’s not a local variable inside each function, it’s local to the class, and therefore global to those functions, while unknown in other tabs. I like globalish variables to be capitalized.
Second, by capitalizing it, I can more easily detect references that need fixing, since they’ll all be nil. (Yes, I know, if my compiler were more strict, it would tell me. It is what it is, and anyway I like it this way.)
I like what we’ve got here. We’ve created one class, and moved everything pertaining to it into another tab, leaving Main tab more concerned with overview matters, and everything from ranks on down encapsulated in the Rank tab. Let’s sum up and call it a morning.
Summing Up
While it is generally a very good idea to throw spikes away, as an experiment, we’ve quickly refactored this rather messy code into far less messy code, that is beginning to take on the shape of a decent design. It’s easy to see that inside Rank, there is an Invader class trying to get out, and we’ll probably do that next time.
There’s still plenty of weird stuff going on, such as the edge detection and reverse handling, but it’s mostly separated out a bit. The Main tab is concerned, in setup, with the global situation of invaders and graphical objects, which will need to be enlarged somehow. And we’ll surely have to push the invader graphics down into the invaders in some fashion.
The draw logic in Main is just drawing the background and my timing display, so it i entirely ad hoc now and will someday be replaced with whatever the background turns out to be.
For now, I think we’ve done a good thing. Tweet me if you agree, or don’t.
See you next time!
Code
-- invadersSpike
function setup()
Rank:createRanks()
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
updateStep = vec2(2,0)
reverse = false
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
end
function draw()
Rank:update(ElapsedTime)
background(40, 40, 50)
rectMode(CENTER)
noFill()
stroke(255)
strokeWidth(2)
rect(WIDTH/2, HEIGHT/2, 224*4, 256*4)
spriteMode(CORNER)
text(DeltaTime, 100,100)
translate(WIDTH/2-224*2, HEIGHT/2)
Rank:drawInvaders()
end
Rank = class()
local Ranks;
function Rank:init(rank, offset, invaders)
self.rank = rank
self.offset = vec2(0,0)
self.invaders = {}
self:initInvaders()
end
function Rank:initInvaders()
for col = 0,10 do
table.insert(self.invaders, {p=vec2(col*16,self.rank*16)})
end
end
function Rank:createRanks()
Ranks = { Rank(0), Rank(1), Rank(2), Rank(3), Rank(4) }
end
function Rank:drawInvaders()
scale(4)
for r,rankEntry in ipairs(Ranks) do
for i,invader in ipairs(rankEntry.invaders) do
local pos = invader.p + rankEntry.offset
if updateStep.x > 0 and pos.x > 224 - 16 then
reverse = true
elseif updateStep.x < 0 and pos.x < 0 then
reverse = true
end
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
function Rank:update(elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
if updateRow == 0 and reverse then
updateStep = - updateStep - vec2(0,2)
reverse = false
end
lastElapsedTime = elapsed
Ranks[updateRow+1].offset = Ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
if updateRow == 0 then updateStep.y = 0 end
end
end
function Rank:draw()
end