Space Invaders 58
Another day of improv. Let’s see what looks worth doing. (Wow, a factory object!)
I’m going to start slowly today, see if I can warm up to something beyond trivial. I’m feeling particularly unintelligent so far this morning, which is probably good if I remain intelligent enough to be cautious. The basic plan is first to look for some simple opportunities to clean things up, then think of a feature that is within my limited abilities.
The Main tab now has only a few functions in it:
- setup - which belongs here
- touched - which belongs here
- draw - which belongs here
- runTests - which I want here
- showTests - which I want here
- drawSpeedIndicator
- rectanglesIntersectAt
- rectanglesIntersect
- addToScore
The addToScore
looks particularly out of place. We do init Score in Main:setup, but addtoScore
is called from Invader and Saucer, when those objects are hit.
The Score is displayed in GameRunner. The Army knows the score values for the invaders and tells each invader her score when she is created. When the score changes, the Army changes the timing between bombs according to a table that it knows. The addScore
function calls the function that adjusts that timing.
The Saucer is supposed to have a dynamically-calculated score but as yet we have not built that. The score depends on the number of shots that the Player has fired, based on a table lookup.
The Invaders know the Army so that they can talk to it. The Saucer does not, but probably should.
It seems to me, looking at this, that addScore
should be a member function on Army, and that we should give the Army instance to the Saucer, and deal with the shot count connection when we improve the Saucer’s scoring.
I’ll begin by giving the army instance to Saucer, since it has an iffy initialization trick right now:
function Saucer:init(test)
self.exploding = 0
if test == nil then
self.alive = false
else
self:go(test)
end
end
The reason for this is that the tests want to test the saucer’s direction, which is based on the number of shots fired, so they pass in a value which runs the saucer’s “go” code right away. Normally the Army tells it to go when it wants it to go. So we’ll make that value second and give it a better name:
function Saucer:init(army, optionalShotsFired)
self.exploding = 0
if optionalShotsFired == nil then
self.alive = false
else
self:go(optionalShotsFired)
end
end
Then the saucer creators need to adjust their calling sequences:
function Army:init(player)
self.player = player
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], army))
end
end
self.weaponsAreFree = false
self.armySize = 55
self.invaderCount = 0
self.invaderNumber = 1
self.overTheEdge = false
self.motion = vec2(2,0)
self:defineBombs()
self.bombDropCycleLimit = 0x30
self.bombCycle = 0
self.saucer = Saucer(self) -- <---
end
Wow, it’s past time to clean that up, isn’t it?
And in tests:
_:test("Saucer starts left, goes right, stops right if shots even", function()
local s = Saucer(nil, 2) -- <---
_:expect(s:startPosition()).is(vec2(8,185))
_:expect(s:stepSize()).is(vec2(2,0))
_:expect(s:stopPosition()).is(vec2(208,185))
end)
And a couple of others.
I expect this all to work. Let’s see why I’m wrong.
Strangely enough, everything works. Commit: saucer knows army.
It’s about time for my chai run but let’s move the addToScore
method over to Army and use it there.
function Army:addToScore(added)
Score = Score + added
TheArmy:adjustTiming(Score)
end
function Invader:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
self.army:addToScore(self.score) -- <---
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
function Saucer:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
self.army:addToScore(self:score())
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
To my surprise that doesn’t work because it turns out that invaders don’t know the army after all. The code that creates them looks like this:
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row],
scores[row], army))
end
end
army
in this context is undefined, therefore nil. Brilliant. Should say:
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], self))
end
end
The invaders now score, but the saucer doesn’t go any more. This will take judgment, brains, and maturity to solve. And chai. Back in a bit …
And … back. Generally by now I’d have an idea for what happened. But in fact I’ve got nothin. Let’s see what the code says.
function Army:runSaucer()
if self.saucer:isRunning() then return false end
if self:yPosition() > 89 then return false end
if math.random(1,20) < 10 then return false end
self.saucer:go(self.player:shotsFired())
return true
end
When is the saucer running?
function Saucer:isRunning()
return self.alive or self.exploding > 0
end
So if the saucer showed alive
we wouldn’t run it. Did our change make us think it’s alive?
function Saucer:init(army, optionalShotsFired)
self.exploding = 0
if optionalShotsFired == nil then
self.alive = false
else
self:go(optionalShotsFired)
end
end
I sure don’t think so. Better recheck the creation:
self.saucer = Saucer(self)
That seems OK too.
When I ran it again, the saucer flew. So maybe it was just that I didn’t beat the odds? I’m not sure. I do know that when I managed to shoot the saucer, I got this:
Saucer:61: attempt to index a nil value (field 'army')
stack traceback:
Saucer:61: in method 'killedBy'
Army:248: in method 'checkForKill'
Army:228: in method 'processMissile'
Missile:26: in method 'draw'
Player:61: in method 'draw'
GameRunner:24: in method 'draw'
Main:27: in function 'draw'
function Saucer:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
self.army:addToScore(self:score()) -- <--- 61
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
I am confused. How can that be nil? Oh. I guess I needed that chai more than I realized:
function Saucer:init(army, optionalShotsFired)
self.exploding = 0
if optionalShotsFired == nil then
self.alive = false
else
self:go(optionalShotsFired)
end
end
We didn’t save it. Brilliant again:
function Saucer:init(army, optionalShotsFired)
self.army = army
self.exploding = 0
if optionalShotsFired == nil then
self.alive = false
else
self:go(optionalShotsFired)
end
end
That should improve things, but I wonder whether there should be a test on Saucer that’s sufficiently robust to have caught that.
There will be in the future, when we implement the scoring, and therefore (probably) exercise the call to army. Right now our score is a literal and the only tests address hits and direction of motion. Now that it’s fixed, I don’t see a test that makes sense to write. Well, that’s “lack thinking”. Let’s do write one.
While writing the test, I notice this code, because I want my test to kill the Saucer:
function Saucer:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
self.army:addToScore(self:score())
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
The code for saucer death is there in the middle of that method. Let’s pull it out:
function Saucer:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self:die()
return true
else
return false
end
end
function Saucer:die()
self.alive = false
self.army:addToScore(self:score())
self.exploding =15
SoundPlayer:play("killed")
end
Now I can test:
_:test("Saucers score", function()
local army = Army()
local s = army.saucer
local oldScore = Score
s:die()
_:expect(Score).isnt(oldScore)
end)
Pretty weak test, but we don’t know what the score should be yet. I expect this to run. It doesn’t, quite, because Score isn’t initialized. This test will have to change when we get a little further down the Score road, but for now:
_:test("Saucers score", function()
local army = Army()
local s = army.saucer
Score = 0
s:die()
_:expect(Score).isnt(0)
end)
That runs. If I remove the saving of army
, which is why we’re writing this, I get:
32: Saucers score -- Saucer:70: attempt to index a nil value (field 'army')
So we have herded the cow back in and closed the barn door behind it. Now we have score updated by army. I so commit: score updated by Army
We could ship this if we needed to. Everything works. Unless I’ve missed something (again) but I am confident.
Let’s pull the score away from a global and put it into Army. I expect this will require a number of changes.
function setup()
runTests()
parameter.boolean("Cheat", false)
Runner = GameRunner()
SoundPlayer = Sound()
invaderNumber = 1
Lives = 4
Score = 0 -- <--- this is right out
Line = image(208,1)
for x = 1,208 do
Line:set(x,1,255, 255, 255)
end
end
Now GameRunner actually displays the score, and I think it should. However, I want to argue that it makes sense for Army to keep the score, since the invaders and saucers all belong to it, and it would rightly be keeping track of casualties. So we’ll have GameRunner ask Army:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(Line,8,16)
textMode(CORNER)
fontSize(10)
text("SCORE " .. tostring(TheArmy:score()), 144, 4) -- <---
tint(0,255,0)
local lives = self.lm:livesRemaining()
text(tostring(lives), 24, 4)
local addr = 40
for i = 1,lives-1 do
sprite(asset.play,40+16*i,4)
end
if lives == 0 then
textMode(CENTER)
text("GAME OVER", 112, 32)
end
drawSpeedIndicator()
popStyle()
end
I’m using a method, not just accessing a variable because our growing coding standard is that we don’t mess with other people’s insides. So in Army:
function Army:score()
return self.score
end
Some inits and other references:
function Army:init(player)
self.player = player
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], self))
end
end
self.weaponsAreFree = false
self.armySize = 55
self.invaderCount = 0
self.invaderNumber = 1
self.overTheEdge = false
self.motion = vec2(2,0)
self:defineBombs()
self.bombDropCycleLimit = 0x30
self.bombCycle = 0
self.saucer = Saucer(self)
self.score = 0
end
function Army:addToScore(added)
self.score = self.score + added
TheArmy:adjustTiming(self.score)
end
There’s that messy init again. We’ll do something with that soon, I promise myself.
There’s just one reference in tests, fixed as follows:
_:test("Saucers score", function()
local army = Army()
local s = army.saucer
local oldScore = army:score()
s:die()
_:expect(army:score()).isnt(oldScore)
end)
That’s how I wanted to do it anyway. I think we should be good to go. Let’s try. A big mistake:
GameRunner:37: attempt to call a number value (method 'score')
stack traceback:
GameRunner:37: in method 'drawStatus'
GameRunner:26: in method 'draw'
Main:26: in function 'draw'
We can’t call the method score and the variable score. Let’s change the method to getScore(). I’ll spare you the listings.
We are now all green except for that ignored test. What was that, anyway, it was supposed to remind me of something:
_:ignore("Lives hack initialized to 4", function()
end)
Yes, we init Lives to 4 and then immediately decrement it when we spawn the first player. That’s odd. We’ll leave it as is.
We can commit: Score is now internal to Army.
Moving Right Along
Let’s do at least some cleaning up in Army:init
:
function Army:init(player)
self.player = player
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], self))
end
end
self.weaponsAreFree = false
self.armySize = 55
self.invaderCount = 0
self.invaderNumber = 1
self.overTheEdge = false
self.motion = vec2(2,0)
self:defineBombs()
self.bombDropCycleLimit = 0x30
self.bombCycle = 0
self.saucer = Saucer(self)
self.score = 0
end
Let’s just extract all the invader creation stuff at the beginning:
function Army:init(player)
self.player = player
self:marshalTroops()
self.weaponsAreFree = false
self.armySize = 55
self.invaderCount = 0
self.invaderNumber = 1
self.overTheEdge = false
self.motion = vec2(2,0)
self:defineBombs()
self.bombDropCycleLimit = 0x30
self.bombCycle = 0
self.saucer = Saucer(self)
self.score = 0
end
function Army:marshalTroops()
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], self))
end
end
end
That’s better already. Now I’d like to extract two methods from marshalTroops
, one to set up the tables and one to use them to create the invaders. But the local variables kind of thread down into the vaders
table, which is custom-made to fit the fact that there is one rank of type 3 invaders, and two ranks of types 2 and 1. And each invader gets two images.
I think we need a new object to help us with this. It’s an army factory or something similar. It’s a MarshallingCenter:
function Army:marshalTroops()
self.invaders = MarshallingCenter():marshalArmy()
end
When we need an army, we just phone up the marshalling center and have them marshal up an army. Should be easy enough:
MarshallingCenter = class()
function MarshallingCenter:init()
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], self))
end
end
end
function MarshallingCenter:marshalArmy()
return self.invaders
end
This was just a paste into the new class. I propose to refactor it a bit but I rather think this is supposed to work. Well, nearly:
Invader:45: attempt to call a nil value (method 'addToScore')
stack traceback:
Invader:45: in method 'killedBy'
Army:224: in method 'checkForKill'
Army:218: in method 'processMissile'
Missile:26: in method 'draw'
Player:61: in method 'draw'
GameRunner:24: in method 'draw'
Main:26: in function 'draw'
We need to pass in the army to init the invaders. Curiously, that will put the code back the way it was earlier today:
function MarshallingCenter:init(army)
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], army))
end
end
end
function Army:marshalTroops()
self.invaders = MarshallingCenter(army):marshalArmy()
end
Curiously, I get the same error:
Invader:45: attempt to index a nil value (field 'army')
stack traceback:
Invader:45: in method 'killedBy'
Army:224: in method 'checkForKill'
Army:218: in method 'processMissile'
Missile:26: in method 'draw'
Player:61: in method 'draw'
GameRunner:24: in method 'draw'
Main:26: in function 'draw'
Ah. Silly me:
function Army:marshalTroops()
self.invaders = MarshallingCenter(self):marshalArmy()
end
I think this is going to have to change, but it really ought to work now. And it does. But I want to do the marshalling in the marshal function and just the initialization reads in the init.
And (darn it) I really think the army should be passed in to the marshal call, not the init.
function MarshallingCenter:init()
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
self.vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
self.scores = {30,20,20,10,10}
end
function MarshallingCenter:marshalArmy(army)
local invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(invaders, 1, Invader(p,self.vaders[row], self.scores[row], army))
end
end
return invaders
end
And I have to reverse out my previous change to this:
function Army:marshalTroops()
self.invaders = MarshallingCenter():marshalArmy(self)
end
This all works. Commit: Army created in MarshallingCenter.
Let’s sum up, mostly focused on Army and the MarshallingCenter, which now look like this:
function Army:init(player)
self.player = player
self:marshalTroops()
self.weaponsAreFree = false
self.armySize = 55
self.invaderCount = 0
self.invaderNumber = 1
self.overTheEdge = false
self.motion = vec2(2,0)
self:defineBombs()
self.bombDropCycleLimit = 0x30
self.bombCycle = 0
self.saucer = Saucer(self)
self.score = 0
end
function Army:marshalTroops()
self.invaders = MarshallingCenter():marshalArmy(self)
end
MarshallingCenter = class()
function MarshallingCenter:init()
local vader11 = readImage(asset.inv11)
local vader12 = readImage(asset.inv12)
local vader21 = readImage(asset.inv21)
local vader22 = readImage(asset.inv22)
local vader31 = readImage(asset.inv31)
local vader32 = readImage(asset.inv32)
self.vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
self.scores = {30,20,20,10,10}
end
function MarshallingCenter:marshalArmy(army)
local invaders = {}
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(invaders, 1, Invader(p,self.vaders[row], self.scores[row], army))
end
end
return invaders
end
There’s still more to do to make Army’s init really good looking but it’s much improved. We’ve added a sort of Invader factory object, which I decided to call a Marshalling Center, which is a place where things are arranged for shipping out. Maybe it should be called InvaderFactory but for now I like the name. Invaders are people too.
But why did I break the function out between init
and marshalArmy
? I did that because what’s in init
is the setup for creating an army, building the data structures we need, and marshalArmy
does the work of creating a new army. We could now use a single marshalling center to create many armies. True, we don’t need that now (nor probably ever) but the distinction between setting up the factory and manufacturing something makes sense to me.
No Idea
I had no idea that we’d build this object. Certainly I wasn’t planning on it for today, and I’m not sure if I’ve even ever thought in passing that a thing like this was needed. I’m sure I’ve noticed how complicated Army:init
was getting, but if I thought of an invader factory object before, I don’t recall it.
What made it come to mind was that there is an obvious break in the original blob of code, between getting ready to create the invaders array, and then creating it. But all the information to create it was in local variables, which meant that to break the method up into the two stages of “prepare” and “do” was difficult. We could have created a “do” method that took a whole raft of parameters, but that was so messy that it didn’t even enter my mind to do that.
When a chunk of code is mostly concerned with its own internals, as this one was, with just the one outside reference to army
, there’s usually an opportunity for a small class that handles just the items in that chunk. And in our case, since it was a creator of a structure, making it into a factory kind of object made sense. Arguably, because it is a factory, we should call it that. I’m going to let the domain name prevail over the implementation name, at least for now.
So my thoughts went quickly to extracting the class, as soon as I had that code isolated into the marshalTroops
method. It was clear that that method had almost nothing to do with the Army.
If that notion doesn’t just spring into a developer’s mind, as it did into mine, what rule of thumb might they follow? Perhaps something like:
If a method, function, or patch of code pays little attention to its current home, and lots of attention elsewhere, it is a candidate for moving. If it mostly refers to things of its own creation, it may be calling out for a new class.
This is a type of “feature envy”, of course, where code refers to information in some other class, but in this case, it refers to code that isn’t in any class … yet.
Anyway, that went well, I think, and we have improved the code substantially this morning. We have no new features, but we have a version that is improved and worth building further upon. We’ll ship it, and ship this article, content with the morning’s work.