Space Invaders 68
Users observe the need for more racks of invaders. That’s going to get … interesting. Also Game Over if they reach bottom..
When you shoot down all the invaders, you’re supposed to get another “rack” of them. I’m told that: I’ve never managed to get them all. The new racks start at lower and lower positions on the screen. There’s a table of values in the assembly code for that.
An additional feature is that when the invaders get to the bottom row, it’s game over. Right now, I think they just keep going. I’m tempted to do this one first, because it’s easy.
The good news is that there’s a place where we know that the aliens are all gone. Or at least we could know it: the facts are there:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
On each invader update, we check to see if we’ve done all of them. We update armySize
, which is used in Sound to change the cycle time of the Ominous Tones. The value we use, invaderCount
is a dynamic count of the number of invaders who report for duty during the update cycle, that is, the live ones. So when that value is zero, all the invaders have gone to their reward. This is the point to trigger a new rack.
The question is how to do it. I believe the answer is to call the MarshallingCenter for a new army:
function Army:marshalTroops(runner)
self.invaders = MarshallingCenter(runner):marshalArmy(self)
end
I noticed those references to runner. No one passes a parameter to the creation of the MarshallingCenter, and it ignores the parameter anyway:
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
So we can remove those runner things right now and commit: removed unused parameter creating MarshallingCenter.
I’m a bit irritated by knowing that since we don’t save the MarshallingCenter, we will reread the assets in the init, but in principle, this should start a new rack when all the invaders are gone:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
if self.invaderCount == 0 then
self:marshalTroops()
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
Now we know I’ll never get down to zero invaders, even with the cheat switch set, so I’ll test it at an easier number … oh … 54.
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
if self.invaderCount <= 54 then
self:marshalTroops()
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
This works well. As soon as I shoot an invader, a new complete rack appears, up where they always start. However, they start dropping bombs right away. I’d like to have that two-second delay before bombing occurs.
There is also a bit of a nicety in the original program, which is that the ranks of invaders appear sequentially, bottom to top. This is an artifact of the drawing algorithm used, which only draws one alien at a time, relying on screen persistence, I guess, to make it look like everyone’s always there. I’m not sure I want to go to the trouble to replicate that, but we might want some kind of indication that a new rack has appeared. Of course, if you’ve just killed the last invader in one rack, a new one appearing will be pretty obvious.
First, though, since this works, let’s set that check back to zero and commit: new rack of invaders after all gone.
Now let’s see about that firing delay. That happens now when a new gunner spawns. We’ll find it somewhere in Army though. Ah:
function Army:canDropBomb()
return self.weaponsAreFree and self.bombCycle > self.bombDropCycleLimit
end
So we’re looking for the handling of the weaponsAreFree
flag. Not free as in beer, by the way. It’s set here:
function Army:weaponsFree()
self.weaponsAreFree = true
end
function Army:weaponsHold()
self.weaponsAreFree = false
end
And that’s called here:
function GameRunner:spawn(isZombie)
Player:instance():spawn(isZombie)
self:setWeaponsDelay()
end
function GameRunner:playerExploding()
TheArmy:weaponsHold()
end
function GameRunner:setWeaponsDelay()
self.weaponsTime = ElapsedTime + 2
end
I showed some surrounding code there. This isn’t as clear as we’d like, is it? Technical debt: the residuum of incomplete ideas, still residing in the code. I presume that we’ll be checking the weaponsTime
in our update:
function GameRunner:update60ths()
self.time120 = self.time120 + (DeltaTime < 0.016 and 1 or 2)
if self.time120 < 2 then return end
self.time120 = 0
Player:instance():update()
TheArmy:update()
if self.weaponsTime > 0 and ElapsedTime >= self.weaponsTime then
TheArmy:weaponsFree()
self.weaponsTime = 0
end
end
So this is a 2 second timer for weapons being off but I don’t like that the weaponsHold
and weaponsTime
are being set separately. You’d think they were linked.
I think what’s going on here is that the Player sends playerExploding
to GameRunner, and we happen to know that there may be a spawn call later. (Since we spawn zombies after GameOver, we know there will be a spawn call.)
The spawn call sets the weaponsDelay:
function GameRunner:spawn(isZombie)
Player:instance():spawn(isZombie)
self:setWeaponsDelay()
end
This seems too intricate for me. It works because certain things happen in a certain order. This is called “Temporal Coupling”, one of a handful of forms of coupling in code. Coupling is necessary, and temporal coupling keeps everything from happening all at once, so to a degree we need it. On the other hand, too much coupling generally makes code hard to understand and therefore hard to change. Temporal coupling is particularly tricky in that regard, because it’s hard to see. We can see that all these functions do these things, but unless we sort of simulate the program in our head, we’re not sure which does what when.
Let’s do ourselves a bit of a favor here, and put a new method on GameRunner:
function GameRunner:twoSecondWeaponsHold()
TheArmy:weaponsHold()
self:setWeaponsDelay(2)
end
The setWeaponsDelay
unconditionally sets 2. Let’s give it the parameter suggested above, and default it to 2.
function GameRunner:setWeaponsDelay(seconds)
self.weaponsTime = ElapsedTime + seconds or 2
end
Now where we start the new rack:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
if self.invaderCount <= 0 then
self:marshalTroops()
Runner:twoSecondWeaponsHold()
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
I expect this to give me a little time. I’ll put my 54 back in and try again. Maybe I’ll do 50.
But first I had to put in these parentheses:
function GameRunner:setWeaponsDelay(seconds)
self.weaponsTime = ElapsedTime + (seconds or 2)
end
The bomb delay does work. I’m going to double check by setting it longer. Yes, it’s really working. I think, though, that we should request a weapons hold, and we should specify the time we want. So:
function GameRunner:requestWeaponsHold(seconds)
TheArmy:weaponsHold()
self:setWeaponsDelay(seconds or 2)
end
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
if self.invaderCount <= 50 then
self:marshalTroops()
Runner:requestWeaponsHold(2)
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
OK, set my count back to zero and commit: new rack has weapons hold.
The feature works. Its implementation isn’t bad, but I’m concerned about the fact that the timing isn’t obvious in most cases.
I think this should be more explicit:
function GameRunner:spawn(isZombie)
Player:instance():spawn(isZombie)
self:setWeaponsDelay()
end
It’s assuming there is a weapons hold in effect. Now that we have this new function, we can use it here:
function GameRunner:spawn(isZombie)
Player:instance():spawn(isZombie)
self:requestWeaponsHold()
end
I think that makes a bit more clear what’s going on.
Let’s take a look at the main chunk we modified:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
if self.invaderCount <= 0 then
self:marshalTroops()
Runner:requestWeaponsHold(2)
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
end
Let’s extract a bit:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
self:cycleEnd()
end
end
function Army:cycleEnd()
if self.invaderCount <= 0 then
self:marshalTroops()
Runner:requestWeaponsHold(2)
end
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
That should make it a bit more clear that we do cycleEnd
when we’ve just done the last invader. What about that new method? Let’s try this:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
self:cycleEnd()
end
end
function Army:cycleEnd()
self:handleEmptyRack()
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
function Army:handleEmptyRack()
if self.invaderCount <= 0 then
self:marshalTroops()
Runner:requestWeaponsHold(2)
end
end
I was taught a principle, called “Composed Method” that says that method should either do things, or call things, but not both. So let’s extract the doing bit from cycleEnd
:
function Army:handleCycleEnd()
if self.invaderNumber > #self.invaders then
self:cycleEnd()
end
end
function Army:cycleEnd()
self:handleEmptyRack()
self:startRackUpdate()
end
function Army:handleEmptyRack()
if self.invaderCount <= 0 then
self:marshalTroops()
Runner:requestWeaponsHold(2)
end
end
function Army:startRackUpdate()
self.armySize = self.invaderCount
self.invaderCount = 0
self.invaderNumber = 1
self:adjustMotion()
end
Now I’m a bit worried about adjustMotion
. That’s the thing that decides whether to step down, reverse, whatever:
function Army:adjustMotion()
if self.overTheEdge then
self.overTheEdge = false
self.motion = self.motion*reverse + self.stepDown
else
self.motion.y = 0 -- stop the down-stepping
end
end
I think we’re good here, but we should probably clear overTheEdge
when we create a new rack, if that’s not already done somewhere. A search tells me that it isn’t. We should do it whenever we marshal new troops.
function Army:marshalTroops()
self.invaders = MarshallingCenter():marshalArmy(self)
self.overTheEdge = false
end
This is another belt-suspenders thing, but I think the logic requires it even if the timing would allow it to work.
I’m happy with this result. As usual, people uncomfortable with lots of tiny methods will be less comfortable. I’d urge them to try the approach for a while, until they can deal with it as well as whatever they’re doing now, and then decide which way they prefer. Or not. I’m not the boss of them.
Commit: refactor Army:handleCycleEnd
.
What Now?
This has gone well. A test broke but then went green all on its own. No idea what that was, no good way to find out. Game plays, tests green now.
What else? Oh there was that thing about game over if the invaders get to the bottom. Let’s see what we can do about that.
The line across the bottom of the screen is at Y = 16:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
...
What about the shields? They are drawn at Y = 48:
function Shield:createShields()
local img = readImage(asset.shield)
local posX = 34
local posY = 48
local t = {}
for s = 1,4 do
local entry = Shield(img:copy(), vec2(posX,posY))
table.insert(t,entry)
posX = posX + 22 + 23
end
Shield.shields = t
end
So it seems to me that if the invaders Y is less than 48, it should be game over. Let’s try that. How will we know? We don’t want to search all the invaders for their Y value, surely.
Live invaders report for duty:
function Invader:update()
self.pos = self.pos + self.army:invaderMotion() -- required for yPosition
if not self.alive then return true end
self.army:reportingForDuty()
self.picture = (self.picture+1)%2
Shield:checkForShieldDamage(self)
if self:overEdge() then
self.army:invaderOverEdge(self)
end
return false
end
What if we have them send in their Y coordinate and Army maintains the minimum value? We’ll have them send in their position:
function Invader:update()
self.pos = self.pos + self.army:invaderMotion() -- required for yPosition
if not self.alive then return true end
self.army:reportingForDuty(self.pos)
self.picture = (self.picture+1)%2
Shield:checkForShieldDamage(self)
if self:overEdge() then
self.army:invaderOverEdge(self)
end
return false
end
In Army, we have:
function Army:reportingForDuty()
self.invaderCount = self.invaderCount + 1
end
We have a gameOver function, which is called when lives run out. So to force game over we could set lives to zero somehow. Only the LifeManager really knows much about lives, though we do have this:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
textMode(CORNER)
fontSize(10)
text("SCORE " .. tostring(TheArmy:getScore()), 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
This rather messy method asks for and uses LM’s livesRemaining count. I think we want to tell GameRunner to tell LifeManager to consume all the lives. We’ll also need to explode the current player.
This is an experiment. If it works I plan to back it out and break.
First this:
function Army:reportingForDuty(invaderPos)
if invaderPos.y < 48 then
Runner:forceGameOver()
end
self.invaderCount = self.invaderCount + 1
end
Then this:
function GameRunner:forceGameOver()
self.lm:forceGameOver()
Player:instance():explode()
end
Then this:
function LifeManager:forceGameOver()
self.life = #self.lives
end
I am just foolish enough to expect this to work. Curiously enough, it does!
Commit: invaders below shields = game over.
Enough already. Let’s sum up.
Summing Up
We got two new features today. A new rack of invaders appears when you manage to finish off a rack. And if the invaders get below the shield line, it’s instant Game Over.
We detected some temporal coupling in the game, where it works because certain objects do things in an order that other objects expect. We removed a bit of that by creating a new method that combined weapons hold and weapons hold time, but there’s still some of that going on.
However, temporal coupling isn’t all bad all the time. Imagine a game that consisted of an independent army of invaders marching and bombing, and independent gunners gunning, an independent life manager providing new gunners for a while, a game runner that just draw the situation on the board and gave everyone a chance to update.
There would be some necessary real communication between those objects. The invaders’ bombs would inform gunners that they were dead, the gunners’ missiles would inform the invaders (and the shields) of damage, and everything would just gracefully take care of itself.
That’s pretty much the situation during game play, and it works nicely and now that I think about it, I’m a bit proud of the fact that it does. But when you get to major “strategic” situations, like there being no army left or invaders landing, things get a bit messier.
They’re not terrible. They might be good enough, because the game is nearly done. But they’re not great, especially if we look toward a more complex situation. Suppose we wanted to build the two or three or more attract mode behaviors that the original game had. Suppose we wanted the easter egg where some text appears and an invader comes out and shoots it down. And so on.
As it stands, we’d look to GameRunner to handle that, and we might fall back on Main dealing with it. But presently those objects have a pretty weak handle on what’s going on. Mostly they just check to see if there are any lives left or some other detail.
I don’t have a solution in mind here, but I wonder whether there doesn’t need to be a sort of StrategicOverview object that keeps track of the whole situation, that can be interrogated, or that sends messages to other components to tell them how to behave. I can imagine that something like that would let us better see that high-level flow than what we have here.
If you look at the original assembly code, you won’t find a thing like that. You’ll find lots of branches asking questions like “is he exploding”, and no coherent chunk of code that looks like strategy. That’s how we often programmed in those days. We didn’t have much space to play with, and at least in my case, we didn’t know better than to do it that way.
Today, we have options, and part of what we do here is to use this tiny ancient program as a place to consider the options available to us today.
Or, in this case, tomorrow or Monday. See you then!