Space Invaders 41 - More Destruction
I think today we’ll work on the bomb timing, and targeting the rolling bomb. Often, what I think will happen is what actually happens.
There are two aspects to the inter-bomb timing of the old program that we want to replicate. One is that as the player’s score increases, the rate of firing changes, according to a table of values. The other is that when the invaders spawn (or maybe when the player spawns (or maybe both)) there are two seconds before they fire.
I am imagining that if we loaded two seconds into our inter-shot timer, it would just work, so my plan is to work on the table-driven settings first.
We now have a fixed delay:
function Army:possiblyDropBomb()
if self:canDropBomb() then
self:dropRandomBomb()
end
end
function Army:canDropBomb()
return self.bombCycle > self.bombDropCycleLimit
end
It’s bombCycleLimit
that’s holding us back, so that’ll be what should looked up from the table. Now to find the old invaders info about what it does.
The reload rate gets faster as the game progresses. The code uses the upper two digits of the player’s score as a lookup into the table at 1AA1 and sets the reload rate to the value in the table at 1CB8. The reload rate for anything below 0x0200 is 0x30. From 0x0200 to 0x1000 the delay drops to 0x10, then 0x0B at from 0x1000 to 0x2000. From 0x2000 to 0x3000 the rate is 8 and then maxes out above 0x3000 at 7. With a little flying-saucer-luck you will reach 0x3000 in 2 or 3 racks.
The table corresponding to this writeup is this:
ShotReloadRate:
; The tables at 1CB8 and 1AA1 control how fast shots are created. The speed is based
; on the upper byte of the player's score. For a score of less than or equal 0200 then
; the fire speed is 30. For a score less than or equal 1000 the shot speed is 10. Less
; than or equal 2000 the speed is 0B. Less than or equal 3000 is 08. And anything
; above 3000 is 07.
;
; 1CB8: 02 10 20 30
;
1AA1: 30 10 0B 08
1AA5: 07 ; Fastest shot firing speed
This makes clear that at 1CB8
we find the score value, and that the 1AA1
table is the firing speed. There’s one more detail I wonder about, and that’s whether the score is stored in BCD. I’m pretty sure that it is.
Ah. Here’s the answer:
AlienScores:
; Score table for hitting alien type
1DA0: 10 ; Bottom 2 rows
1DA1: 20 ; Middle row
1DA2: 30 ; Highest row
Since the scores for the invaders are 10, 20, 30 decimal, and these are hex numbers, we know the scores are in BCD. So we can translate the value checks accordingly.
The next question is where to do this resetting. Since we’re counting up and checking for cycle time greater, we can pretty much set the bombCycleLimit
whenever we want to. But it seems to me that it makes sense to do it when we add to the score.
Here we run into some “legacy code”, a design that was good for the time but is not so good now:
function Invader:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
Score = Score + self.score
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
We’re just jamming the value into a global variable Score. This happens to be the only place, other than initialization, where we add to the score. It seems like overkill to create an object for Score, at least not right now, but we can encapsulate the desired behavior this way:
function Invader:killedBy(missile)
if not self.alive then return false end
if self:isHit(missile) then
self.alive = false
addToScore(self.score) -- <---
self.exploding =15
SoundPlayer:play("killed")
return true
else
return false
end
end
-- Main
function addToScore(added)
Score = Score + added
end
Now we have a convenient central place to do our adjustment. Isn’t that nice? We could even rename the Score
variable for extra protection, if we didn’t trust ourselves so much.
What about the adjustment? We could have Main own it, but the drift should in general be away from Main owning ideas. (We might even argue that we should have found a better place than Main for our addToScore
function.) We could let it live in GameRunner, on the grounds that it’s a game property. But since the value is used in Army, I think it should be adjusted in Army:
function addToScore(added)
Score = Score + added
TheArmy:adjustTiming(Score)
end
function Army:timingValue(score)
return score<=200 and 0x30
or score<=1000 and 0x10
or score<=2000 and 0x0B
or score<=3000 and 0x08
or 0x07
end
function adjustTiming(score)
self.bombDropCycleLimit = self:timingValue(score)
end
There’s just one bitty little problem here, which is the scaling of those values. I think we’re only checking and incrementing every third time through, like the original, so I’m hoping this is OK. the values seem pretty short, though, given that we’re using 50 now, for a firing rate of a bit more than once per second. We’ll see.
A separate issue is testing. Not TDD style: I decided this wasn’t worth that (I may be wrong). But game-play testing. It’ll be a cold day when I get scores over 3000.
I think I’ll add a parameter to the game, that will add n thousand to the score:
function setup()
parameter.integer("ScoreHelp",0,4)
...
I’d like that value to show up in the score display, for better understanding of how I’m doing. What’s a decent way to do that? I think I’ll just add in the value at display time, and in the timing adjustment. This is a temporary thing anyway.
text("SCORE " .. tostring(Score+ScoreHelp*1000), 144, 4)
function Army:timingValue(rawScore)
local score = rawScore + ScoreHelp*1000
return score<=200 and 0x30
or score<=1000 and 0x10
or score<=2000 and 0x0B
or score<=3000 and 0x08
or 0x07
end
Let’s see what happens. Nothing happens, because the game doesn’t know that I’ve scored unless I shoot something to trigger the cycle update. I guess I have to do at least some work here.
Then what happens is:
Main:137: attempt to call a nil value (method 'adjustTiming')
stack traceback:
Main:137: in function 'addToScore'
Invader:45: in method 'killedBy'
Army:172: in method 'checkForKill'
Army:166: in method 'processMissile'
Missile:26: in method 'draw'
Gunner:42: in function 'drawGunner'
GameRunner:22: in method 'draw'
Main:26: in function 'draw'
I left the Army
off this:
function Army:adjustTiming(score)
self.bombDropCycleLimit = self:timingValue(score)
end
A TDD test would have caught this sooner, even a trivial one. Make a note of that.
Here’s the rain of fire at score 4000:
I haven’t found any on-line videos of scores above about 1000, so that wasn’t much help. It may be that after game-play we’d want to scale that count number a bit. I certainly notice the speedup between no score and 200. It steps from 0x30 to 0x10, or from 48 down to 16, three times faster. And the 0x30 is close to my original rate of 50, so I think we’ve probably got the right values. That means that the game is a monster to play, at least for me, and with the controls we have on the iPad.
I think we’re done here. I’ll remove the parameter and its references and commit: score-dependent bomb rate.
Targeting
OK, now what about targeting? The rolling bomb is supposed to be targeted. Based on my reading of the code (and mostly the comments) what this means is that when the rolling bomb wants to fire, it check’s the player’s x coordinate, and if there is a non-empty column of invaders over that position, it drops a bomb from that column. If there is no such column, it doesn’t fire.
Our current code looks like this:
function Army:dropRandomBomb()
local bombs = {self.rollingBomb, self.plungerBomb, self.squiggleBomb}
local bombType = math.random(3)
self:dropBomb(bombs[bombType])
end
function Army:dropBomb(aBomb)
if aBomb.alive then return end
self.bombCycle = 0
aBomb.alive = true
local col = aBomb:nextColumn()
local leader = self.invaders[1]
local leaderPos = leader.pos
local leaderX = leaderPos.x
local bombX = 8 + leaderX + (col-1)*16
aBomb.pos = vec2(bombX, leaderPos.y - 16)
end
Our bombs aren’t objects, and they don’t know their type. If they were objects, we could perhaps override nextColumn
. We’d have to deal with the possibility that it wouldn’t find one but that would be easy enough.
What is nextColumn
like?
function Bomb:nextColumn()
local next = self.columnTable[self.columnIndex]
self.columnIndex = self.columnIndex+1
if self.columnIndex > self.columnEnd then
self.columnIndex = self.columnStart
end
return next
end
Let’s do an override of this the old fashioned way, with a flag, targeted
, false except in the rolling shot. So, in init:
function Bomb:init(pos, shapes, explosion, columnStart, columnEnd)
self.columnStart = columnStart
self.columnEnd = columnEnd
self.columnIndex = columnStart
self.pos = pos
self.shapes = shapes
self.explosion = explosion
self.shape = 0
self.cycle = 2
self.alive = false
self.explodeCount = 0
self.columnTable = {0x01, 0x07, 0x01, 0x01, 0x01, 0x04, 0x0B, 0x01, 0x06, 0x03, 0x01, 0x01, 0x0B, 0x09, 0x02, 0x08, 0x02, 0x0B, 0x04, 0x07, 0x0A}
end
We can either pass another flag, or do something weird with columnStart
or columnEnd
. The latter would be vaguely obscene. We’ll have a flag:
function Bomb:init(pos, shapes, explosion, targeted, columnStart, columnEnd)
self.targeted = targeted
self.columnStart = columnStart
self.columnEnd = columnEnd
...
function Army:defineBombs()
...
self.rollingBomb = Bomb(vec2(0,0), bombTypes[1], bombExplosion, true, 1,16)
self.plungerBomb = Bomb(vec2(0,0), bombTypes[2], bombExplosion, false, 1,16)
self.squiggleBomb = Bomb(vec2(0,0), bombTypes[3], bombExplosion, false, 7,21)
end
Now, we can do something about the column selection.
I think we’d better TDD this, it’s rather intricate.
A bit of up-front design here. The column covering the gunner is a function of the current position of the lead invader. The invader columns are 16 wide. The gunner’s coordinate can be anything between 0 and 208, and it has a width of 16.
Let’s write a test. An easy one, which will be hard enough:
_:test("rolling shot finds correct column", function()
local theArmy = Army()
setupGunner()
local bomb = theArmy.rollingBomb
theArmy:setPosition(10,180)
Gunner.pos = vec2(10,32)
local col = bomb:nextColumn()
_:expect(col).is(1)
end)
We set up army, gunner, and a bomb. We invented a new command on Army, setPosition
, which is supposed to line up the whole army on that position. We don’t really have anything like that now: we set up the invaders once and then increment them about. I don’t think we’ll need to fiddle the invaders but we’ll see. Anyway we align the Gunner with the Army, call nextColumn
on the bomb and expect the first column number 1 because 1 origins.
This will drive out some behavior. First, setPosition
:
function Army:setPosition(pos)
self.pos = pos
end
This isn’t updated by anyone, but we’ll deal with that in due time. Curiously the test passes now:
19: rolling shot finds correct column -- OK
I guess it accidentally returns 1 from the table. No problem:
_:test("rolling shot finds correct column", function()
local theArmy = Army()
setupGunner()
local bomb = theArmy.rollingBomb
theArmy:setPosition(10,180)
Gunner.pos = vec2(10,32)
local col = bomb:nextColumn()
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
end)
That fails nicely:
19: rolling shot finds correct column -- Actual: 7, Expected: 2
Now we get to do some work.
function Bomb:nextColumn()
if self.targeted then return self:targetedColumn() end
local next = self.columnTable[self.columnIndex]
self.columnIndex = self.columnIndex+1
if self.columnIndex > self.columnEnd then
self.columnIndex = self.columnStart
end
return next
end
As I start the real code, I realize I only care about the x position. We’ll go back and deal with that when next this works.
OK, I kind of pulled this out of um, the air:
function Bomb:targetedColumn()
local gunnerPos = Gunner.pos.x
local armyPos = TheArmy:xPosition()
local relativePos = gunnerPos - armyPos
local result = 1 + relativePos//16
return math.max(math.min(result,11),1)
end
function Army:setPosition(pos)
self.invaders[1].pos = pos
end
function Army:xPosition()
return self.invaders[1].pos.x
end
I changed the Army’s setPosition
thing to store the position in the first alien, and to fetch xPosition
from there. The code does not seem to work. By which I mean, it doesn’t work:
~~~lua 19: rolling shot finds correct column – Actual: 1.0, Expected: 2
Let's verify the test:
~~~lua
_:test("rolling shot finds correct column", function()
local theArmy = Army()
setupGunner()
local bomb = theArmy.rollingBomb
theArmy:setPosition(10,180)
Gunner.pos = vec2(10,32)
local col = bomb:nextColumn()
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
end)
A lot of yak shaving has told me, so far, that jamming that value into the first invader doesn’t work. I push 10 into it, and the call to xPosition
is returning 16. The first invader’s position at that point is (16,105), which is his official initialized value. How is it that it’s getting set back?
I’ll add an expect
:
_:test("rolling shot finds correct column", function()
local theArmy = Army()
setupGunner()
local bomb = theArmy.rollingBomb
theArmy:setPosition(vec2(10,180))
_:expect(theArmy.invaders[1].pos).is(vec2(10,180))
Gunner.pos = vec2(10,32)
_:expect(theArmy.invaders[1].pos).is(vec2(10,180))
local col = bomb:nextColumn()
_:expect(theArmy.invaders[1].pos).is(vec2(10,180))
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
end)
Ah. The problem is this code:
function Bomb:targetedColumn()
local gunnerPos = Gunner.pos.x
local armyPos = TheArmy:xPosition()
local relativePos = gunnerPos - armyPos
local result = 1 + relativePos//16
print("relpos", gunnerPos, "-", armyPos,"+",relativePos, "//16=", relativePos//16, "+1=", result)
return math.max(math.min(result,11),1)
end
This refers to the global TheArmy
, which is the official one, not the one in our test. Right now, I think I’ll patch this to work, but the better solution is to notice that this bomb method wants either to be a method on Army (most likely) or on Gunner. It refers to no bomb values at all.
First I want to make it work. I’m very tempted to make it right but on a red bar, suffering from recent confusion, this isn’t a super idea. So …
_:test("rolling shot finds correct column", function()
local cacheArmy = TheArmy
TheArmy = Army()
setupGunner()
local bomb = TheArmy.rollingBomb
TheArmy:setPosition(vec2(10,180))
_:expect(TheArmy.invaders[1].pos).is(vec2(10,180))
Gunner.pos = vec2(10,32)
_:expect(TheArmy.invaders[1].pos).is(vec2(10,180))
local col = bomb:nextColumn()
_:expect(TheArmy.invaders[1].pos).is(vec2(10,180))
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
TheArmy = cacheArmy
end)
This passes. I can remove all those extra checks, and set up a few more cases.
_:test("rolling shot finds correct column", function()
local cacheArmy = TheArmy
TheArmy = Army()
setupGunner()
local bomb = TheArmy.rollingBomb
TheArmy:setPosition(vec2(10,180))
Gunner.pos = vec2(10,32)
local col = bomb:nextColumn()
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
Gunner.pos = vec2(30,32)
_:expect(col).is(2)
Gunner.pos = vec2(0,32)
_:expect(col).is(1)
TheArmy = cacheArmy
end)
This fails:
19: rolling shot finds correct column -- Actual: 2.0, Expected: 1
That’s the last test, with the (0,32). What’s up with that?
Might help if I’d make the call.
_:test("rolling shot finds correct column", function()
local cacheArmy = TheArmy
TheArmy = Army()
setupGunner()
local bomb = TheArmy.rollingBomb
TheArmy:setPosition(vec2(10,180))
Gunner.pos = vec2(10,32)
local col = bomb:nextColumn()
_:expect(col).is(1)
Gunner.pos = vec2(26,32)
col = bomb:nextColumn()
_:expect(col).is(2)
Gunner.pos = vec2(30,32)
col = bomb:nextColumn()
_:expect(col).is(2)
Gunner.pos = vec2(0,32)
col = bomb:nextColumn()
_:expect(col).is(1)
TheArmy = cacheArmy
end)
This runs. One more check, which will be well off the other end:
Gunner.pos = vec2(208,32)
col = bomb:nextColumn()
_:expect(col).is(11)
The nextColumn
function is working as specified, return the column over the gunner, or the closest one to it. (That’s not what I thought it was, I thought it wouldn’t fire if it couldn’t target, but further reading tells me that it just does its best. There is something about skipping a turn, but I’ve not figured that out.
At this point, I expect to see the rolling bombs falling over the gunner all the time. Let’s run and see.
I need some help making sure that all rollers are over the gunner. I’m going to color them red.
This code is definitely working. Here’s a video.
Amusingly, while filming that, I let myself get killed by a non-targeted bomb. I’m a programmer, Jim, not a gamer.
I’ll remove the red and commit: targeted rollers.
It’s well past my early lunch time. I’ll go grab that and sum up afterward.
Remind me to talk about the learning from the testing.
Summing Up
We actually got done what we had in mind, so that’s a good thing. I think we ran a bit over on time, but I never felt truly buried or in trouble, though that global reference had me guessing for longer than it should. Also it should have never happened: the method doesn’t really belong there, and whoever does it, they should be passed anyone they want to converse with as parameters.
Which reminds me, I was so ready for lunch that I didn’t refactor that. And thinking of it now, I wonder whether we might do better to have the whole calculation done by the army and the firing directed “from above” as it were. We’ll look into that tomorrow.
I’m glad I chose to TDD the feature. It helped me focus on what I needed to do, and made it much easier to work out why it wasn’t working at first. Had I not had those tests, it would have been quite confusing, because I’d never really know any of the gozintas and gozoutas as it went along on the screen.
The test should have given me more of a clue that the design was weird, when I had to push my local army into the global one. That’s of course one of the things that happens with globals and testing. You often have to mock them, fake them, hammer them, and work around them. Of course I’ve been living with globals in the absence of ways I like better. Even had the army been set up as a singleton, we’d have had to hammer that.
Nonetheless, it was a problem until I figured out that hammering was necessary, it was probably a mistake not to find a way to avoid the use of the global right away, and as things stand now it’s a clear indication of a design issue.
But we’re not into Rework Avoidance Theory here, we accept and embrace that we’re evolving the program, and we’ll evolve that to better, probably in the next installment.
I already know what I’ll call it: “Space Invaders 42”! Neat, huh?
See you then!