Space Invaders 5
I need an idea. Representation is important.
As I prepared to start my day, including a first since May 11, a trip out for an iced chai latte, I was thinking about an interesting problem. We have some number of ranks of invaders, marching side to side and inexorably downward. We are issuing a withering fire from below, decimating the ranks of the evil alien invaders. And still they march.
They march from one side of the screen to the other, and at each end they take another terrifying step closer, ever closer, and then march back to the other side.
Meanwhile, our shots are destroying the terrifying aliens who are bent on our planet’s destruction. (Don’t they know we’d rather do it ourselves? But I digress.)
How do the aliens know how far to march, side by side, in the presence of the attrition dealt out by our brave troops on the ground? If all the alien #$%^$#! in the leftmost file are destroyed, the whole group marches one file closer to the side.
The question I’m struggling with is “how do they know”? It would be fairly easy if the left- and rightmost alien were in the first rank, we could just check their position. But the outermost alien $%$#@! might be in any rank. We could search for the outermost, but we’d have to search on every step, since the state of affairs toward the end of a march could be quite different from the situation at the beginning.
There are only 55 possible aliens, and a search of those can’t take long. A quick benchmark tells me that I can search them a million times in 5 seconds. That’s 5 million microseconds, so I can search them in 5 microseconds. Not bad. Should fit within the 120th of a second fastest possible clock fairly well. But it still seems wrong to me.
Am I over-thinking this? Yes, I am. Let’s make a note to express our intention and deal with the implementation later. As we begin each step in a given direction, we’ll ask for the limit in some useful form. We’ll base our decision on whether to turn around on the limit value. To begin, if we don’t have a better idea, we’ll implement the limit by a search. If we have a better idea, we’ll implement the limit that way. Either way, it’ll be hidden inside the limit provider.
Let’s turn our attention now to our spike and see about elaborating it to march back and forth, and then maybe to deal with new limits.
The original Space Invaders screen size was 224 pixels wide by 256 pixels long. If we use our full screen height (in landscape mode), we get 1024 pixels. (That’s why I used scale 4 in the spike.) We’d like the screen centered in the landscape, and it’ll use 224*4 or 896 pixels, half of which is 298, so our left-right margins should be at screen width - 298 and screen width + 298. I think I’ll draw a rectangle there for viewing purposes.
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/4, HEIGHT/2)
scale(4)
for i,at in ipairs(invaders) do
local v = vaders[at.v+1]
sprite(v,at.p.x,at.p.y)
end
end
OK, that looks decent, and the evil invaders are definitely not honoring the edge right now. We haven’t taught them how yet.
I’ve had some other thoughts. One of them is that the invaders ought to be immutable in our real game. Why? Because immutable objects are less error prone. The code to deal with them is generally simpler. And we’ll save all that time updating them.
So let’s work on this spike to get there. The aliens are presently initialized to column and row numbers that differ by 16, starting from zero, 16, 32, and so on. That gives us the proper square spacing of the ranks and files. But our update function looks like this:
function update(elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
lastElapsedTime = elapsed
for col = 1,11 do
local vader = invaders[updateRow*11+col]
vader.p = vader.p + vec2(2,0)
vader.v = (vader.v + 1)%2
end
updateRow = (updateRow + 1)%5
end
end
Every time our elapsed time is large enough, we update one row by incrementing all the coordinates. Presumably we’d decrement them on the way back. I can see no way that cunning plan could go wrong.
We do want to preserve the “one row at a time” behavior, however, to match the ripply way the invaders move in the original game.
We could create a separate table for each rank instead of one big table. And what if the table included the specific aliens for that rank, represented by a little table with just the position in it, and maybe a pointer back to the rank table if we need it (I predict that we will, but we’ll wait until we do). The rank table could contain the left corner address for that rank, and how about if it told the invaders what bitmap to display? That sounds good to me. Let’s try it.
Suppose we initialize like this (untested):
function setup()
ranks = {}
for rank = 0,4 do
local rankTable = {}
local rank = {rank=rank, table=ranktable}
table.insert(ranks,rankTable)
for col = 0,10 do
table.insert(rankTable, {p=vec2(col*16,row*16)})
end
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
--timeSearch()
end
I reckon that would give me a table of five tables, each table containing a table named table (ok, I’ll change that as soon as I end this paragraph) containing the invaders in that rank, and a variable named rank, holding the rank number 0-4 of that rank. Renaming table
…
function setup()
ranks = {}
for rank = 0,4 do
local rankTable = {}
local rank = {rank=rank, invaders=ranktable}
table.insert(ranks,rankTable)
for col = 0,10 do
table.insert(rankTable, {p=vec2(col*16,row*16)})
end
end
vader1 = readImage(asset.documents.Dropbox.inv11)
vader2 = readImage(asset.documents.Dropbox.inv12)
vaders = {vader1,vader2}
updateRow = 0
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
--timeSearch()
end
I’ll comment out the call to update and make draw work. I wish I had the testing framework in this spike, but I don’t, so we’re going to gut it out here. Don’t do this at home.
OK, I shouldn’t really do it at home either. Here’s what worked after about five minutes:
function setup()
ranks = {}
for rank = 0,4 do
local rankTable = {}
rankTable.rank = rank
rankTable.invaders={}
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
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
--timeSearch()
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/4, HEIGHT/2)
scale(4)
for r,rank in ipairs(ranks) do
for i,invader in ipairs(rank.invaders) do
local pos = invader.p
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
The global table ranks
contains five tables, each of which has two elements, rank, from 0 to 4 (unused), and invaders
, a table of the invaders in that rank. The names still seem confusing, but we’re learning here. And the ranks do display. They don’t move, yet.
Note that each invader is presently drawn at her own location, 0,16,32, whatever. That spreads them out across the screen nicely, because we’re at scale 4. I should, however, remove that translate to see where they line up really … way over to the left, I imagine.
OK, as expected. Since we aren’t clear yet how to manage our margins, let’s just put the basic margins into the draw, and honestly, I think we should do that with translate. We’ll just use a better one:
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)
scale(4)
for r,rank in ipairs(ranks) do
for i,invader in ipairs(rank.invaders) do
local pos = invader.p
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
This gets our guys left justified, and tells us that the base offset value we presently should add to the invader positions is (0,0): We do need an offset for each rank, because they move separately.
So that needs to be stored in the individual rank table:
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
lastElapsedTime = ElapsedTime
timeToUpdate = 10/60
--timeSearch()
end
Now we can draw like this:
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)
scale(4)
for r,rankEntry in ipairs(ranks) do
for i,invader in ipairs(rankEntry.invaders) do
local pos = invader.p + rankEntry.offset -- <---
local v = vader1
sprite(v, pos.x, pos.y)
end
end
end
And update like this:
function update(elapsed)
if elapsed >= lastElapsedTime + timeToUpdate then
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + vec2(2,0)
updateRow = (updateRow + 1)%5
end
end
And the aliens march to the right:
They do not, of course, turn around.
Let’s do a quick and dirty approach to that … this is a spike after all. We’re just trying to learn how to do this.
We should turn around if any alien’s position is enough to take him over the edge. He’s over the edge if his current drawn position is greater than equal to our right margin, which is uh WIDTH/2 plus 224*2 - 16 because he’s 16 wide. I think. Maybe. I would draw this on paper but I don’t have any near by. Anyway with 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 pos.x > 224 - 16 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
reverse = false
end
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
end
end
We get this back and forth thing at the far edge:
The reversal triggers nicely, but of course it triggers again right away. Our real reversal logic needs to be sensitive to which way we’re going, and only reverse if we’re over the corresponding edge. Even so it’s rather messy but let’s make it work before we make it right.
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
So that works. My morning session is up, with working code that needs improvement. Let’s sum up.
Summing Up
This seems even more chaotic than my usual fumblings. I’m sure that’s partly because I’m trying to do really bun-simple code without objects, which are where my thoughts like to live. And partly, perhaps I’m losing it in my old age. And partly, this is a weird problem, at least to me.
But now we have code, rife with globals and assumptions, that more or less accurately moves our aliens back and forth across the screen. Since this is a spike to figure out how to do it, it’s a fairly decent step. I’ll want to think a bit about what we’ve learned here, but I have at least one thing I’d like to put out there right now.
The “Lua way” seems often to be to just set up tables, stuff values in them, and then pass them around, much as we’re doing here. Then whoever is passed the table rips its guts out and uses them as it sees fit, perhaps stuffing new presumably similar guts back in.
I hate that, and it’s not just a matter of personal taste. It’s a matter of cohesion and coupling. When we define a table like our rank table, with a rank number and a list of invaders and an offset that seems to be something like a left margin but not exactly, and then we use that table as we have here, we have code all over that references aspects of that table. In our case we have access in setup, which has to get the format right, in draw, which has to use the same terms in a consistent way, and in update, which has to use them consistently and even update them.
Each of those functions has other things to do. I have factored out the drawing a bit, but it’s still all part of one big main program.
Things that swing together should live together. That’s what objects are about. Ideally, only the object itself knows what’s going on inside, and outsiders only know how to ask it to do things. We don’t always get perfect encapsulation with objects, but we get closer.
So, for me, working this way is an inferior way of working. For people less familiar with, or less into objects, I wouldn’t be surprised if they said what we’re doing here was more to their liking. I won’t say they’re wrong, but if they’re as good with objects even as I am – and I’m far from the best – and they still don’t prefer objects, I’d like to chat with them about why.
Be that as it may, I think from here we’ll move for better encapsulation. That’s for next time. See you then!
-- 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
--timeSearch()
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
reverse = false
end
lastElapsedTime = elapsed
ranks[updateRow+1].offset = ranks[updateRow+1].offset + updateStep
updateRow = (updateRow + 1)%5
end
end
function timeSearch()
local t = os.time()
for i = 1,1000000 do
local max = -1
for j,a in ipairs(invaders) do
if a.p.x > max then
max = a.p.x
end
end
end
print(os.time() - t)
end