Space Invaders 74
Displaying two player score. Beyond that, we’ll see.
I wasn’t planning to support two players, but I suppose that if we’re going to do that we should display both scores. I think we should put them both across the screen, way up top where no one ever goes. And if we make those values change dynamically, as who wouldn’t, we can remove the one from the bottom of the screen.
At least that’s my naive plan without looking at the code. Now to look at the code:
The active GameRunner draws status:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
textMode(CORNER)
fontSize(10)
text("Player "..self.playerName.." SCORE " .. tostring(self.army:getScore()), 125, 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
Let’s extract the score-drawing bit. My reasons for that include:
- We’re going to work on it, and this method does many things, and we like methods that do one thing;
- I am thinking I’ll just ask both GameRunners to draw the score all the time. We’ll see if that works out.
So …
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
textMode(CORNER)
fontSize(10)
self:drawScore()
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
function GameRunner:drawScore()
text("Player "..self.playerName.." SCORE " .. tostring(self.army:getScore()), 125, 4)
end
This of course works just fine, and we can commit: refactor out drawScore. It’s a tiny pain to commit: I have to switch to the Working Copy task, press “commit”, paste in the commit message, press “all”, press “commit”, and come back. It’d be nice if it were even easier. I need all the help I can get. Still, a commit on something this trivial, that’s something to be proud of.
Now I want to shorten the message, change it to look a bit more like the old game, and move it to top left. The change is that the original game displays the player number as <1> or <2>. It displays, roughly:
SCORE <1>
0000
I really don’t want to do that, because the spacing will be difficult. Therefore, let’s do it. Is there a leading zero format provided in whatever string formatting thing Codea uses? Probably. I’ll have to look it up.
Speaking of the name, I’m a bit concerned about my trick renaming the zombie player’s GameRunner to Z. That clobbers the name in GameRunner. Cute trick but it’s going to break this feature. We may have to do something different about that.
Is this the price of cleverness? Perhaps it is. Anyway, first cut:
function GameRunner:drawScore()
local output = "Score <"..self.playerName..">\n" .. tostring(self.army:getScore())
sx,sy = textSize(output)
text(output, 8, 256-sy)
end
This gives me this look:
Not bad. Needs to be all upper case, and we need that leading zero formatting that was so hot back then.
I refactor to this:
function GameRunner:drawScore()
local score = tostring(self.army:getScore()
local output = "SCORE <"..self.playerName..">\n" .. score)
sx,sy = textSize(output)
text(output, 8, 256-sy)
end
Now the last time I did something like what I’m about to do, Dave1707 of Codea forum fame, schooled me about the fact that Codea has c-style formatting built in. I’m going to do it anyway.
function GameRunner:drawScore()
local score = "0000" .. tostring(self.army:getScore())
score = score:sub(-4,-1)
local output = "SCORE <"..self.playerName..">\n " .. score
sx,sy = textSize(output)
text(output, 8, 256-sy)
end
Now we have this:
So that’s nice. What about the other score? I have this in mind:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
textMode(CORNER)
fontSize(10)
Player1:drawScore()
Player2:drawScore()
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
Now if drawScore
were just smart enough to draw in two locations, we’d be good. I am minded to let the function decide, although we could also pass the x origin in from here. But, you see, I have a trick in mind. When it’s a one player game, Player1 = Player2.
Nah. Too clever by half. We’ll do this:
function GameRunner:drawStatus()
pushStyle()
tint(0,255,0)
sprite(self.line,8,16)
textMode(CORNER)
fontSize(10)
self:drawScores()
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
function GameRunner:drawScores()
Player1:drawScore(8)
if Player1 ~= Player2 then
Player2:drawScore(224-60)
end
end
function GameRunner:drawScore(x)
local score = "0000" .. tostring(self.army:getScore())
score = score:sub(-4,-1)
local output = "SCORE <"..self.playerName..">\n " .. score
sx,sy = textSize(output)
print(sx)
text(output, x, 256-sy)
end
I’m still too clever for my shoes. Since Player1 and Player2 change positions back and forth, the scores at the top display back and forth. So this is a good idea but not quite a great one.
However:
function GameRunner:drawScores()
Player1:drawScore()
if Player1 ~= Player2 then
Player2:drawScore()
end
end
function GameRunner:drawScore()
local x = 8
if self.playerName == "2" then
x = 224-60
end
local score = "0000" .. tostring(self.army:getScore())
score = score:sub(-4,-1)
local output = "SCORE <"..self.playerName..">\n " .. score
sx,sy = textSize(output)
print(sx)
text(output, x, 256-sy)
end
This give us the desired result during play:
Wow, I should head out for my chai. Maybe I can miss the school buses. Commit: two scores display. BRB.
Well, that took a bit longer than anticipated: I didn’t miss the school buses. Did you know that the plural of bus is buses. I did miss the busses, if there were any.
Now the scores look good during game play, but when the game drops into attract mode odd things happen. I think we should just not display score when the zombies are playing. We don’t have a direct way to check that but we do have these two methods:
function GameRunner:spawningZombie()
self.playerName = "Z"
self.army:zeroScore()
self:soundPlayer():setVolume(0.05)
end
function GameRunner:spawningNormal()
self:soundPlayer():setVolume(1.0)
end
That seems convenient. How about adding a status flag in GameRunner, like this:
function GameRunner:spawningZombie()
self.runningZombie = true
self.playerName = "Z"
self.army:zeroScore()
self:soundPlayer():setVolume(0.05)
end
function GameRunner:spawningNormal()
self.runningZombie = false
self:soundPlayer():setVolume(1.0)
end
And then do this:
function GameRunner:drawScores()
if self.runningZombie then return end
Player1:drawScore()
if Player1 ~= Player2 then
Player2:drawScore()
end
end
That should do the trick. With an init to true
in GameRunner init
it does. I think I’d like to move the text down about a half line, so:
function GameRunner:drawScore()
local x = 8
if self.playerName == "2" then
x = 224-60
end
local score = "0000" .. tostring(self.army:getScore())
score = score:sub(-4,-1)
local output = "SCORE <"..self.playerName..">\n " .. score
sx,sy = textSize(output)
print(sx)
text(output, x, 252-sy)
end
That looks good. Commit: player scores shown at top. Ship it.
Shots not completing
I think I’ve noticed something. There is only one missile, and so you’re not supposed to be able to fire until the running missile has finished its run. However, in rapid fire, when you hit a falling bomb, it appears that rapid tapping will fire too soon, literally calling the missile back to begin again (since there is only one).
Let’s see if we can figure out whether this is happening, ad if so, why. And fix it, that goes without saying except that I just said it.
When the game is running, touches go straight to the Gunner:
function Gunner:touched(touch)
local fireTouch = WIDTH-195
local moveLeft = 97
local moveRight = 195
local moveStep = 1.0
local x = touch.pos.x
if touch.state == ENDED then
self.gunMove = vec2(0,0)
if x > fireTouch then
self:fireMissile()
end
end
if touch.state == BEGAN or touch.state == CHANGED then
if x < moveLeft then
self.gunMove = vec2(-moveStep,0)
elseif x > moveLeft and x < moveRight then
self.gunMove = vec2(moveStep,0)
end
end
end
So that leads to this:
function Gunner:fireMissile()
if (self.count == 0 and (self.alive or self.zombie)) and self.missile.v == 0 then
self:unconditionallyFireMissile()
end
end
function Gunner:unconditionallyFireMissile(silent)
self.missile.pos = self.pos + vec2(7,5)
self.missile.v = 1
self.missileCount = self.missileCount + 1
if not silent then Runner:soundPlayer():play("shoot") end
end
It seems that we’ll fire only if the missile isn’t moving, i.e. missile.v
is zero. I wonder if the short missile is hitting an invisible target, perhaps a bomb that is being destroyed or something like that.
This looks a bit odd:
function Missile:draw()
if self.v == 0 then return end
pushStyle()
if self.explodeCount > 0 then
tint(self:explosionColor())
sprite(self.explosion, self.pos.x - 4, self.pos.y)
self.explodeCount = self.explodeCount - 1
if self.explodeCount == 0 then
self.v = 0
end
else
rect(self.pos.x, self.pos.y, 2,4)
end
popStyle()
if self.v > 0 then Runner.army:processMissile(self) end
end
If the missile is exploding, it displays its explosion and counts down self.explodeCount
. Only when that gets to zero does it set v
to zero. That seems OK. But at the bottom, we call processMissile
…. even if we’re exploding. That seems wrong. Let’s move that:
function Missile:draw()
if self.v == 0 then return end
pushStyle()
if self.explodeCount > 0 then
tint(self:explosionColor())
sprite(self.explosion, self.pos.x - 4, self.pos.y)
self.explodeCount = self.explodeCount - 1
if self.explodeCount == 0 then
self.v = 0
end
else
rect(self.pos.x, self.pos.y, 2,4)
Runner.army:processMissile(self)
end
popStyle()
end
I’ll try that for game play.
I definitely saw the short fire happen that time, and the second missile may have actually disappeared before I fired. That is, maybe it’s not firing too soon, but the prior missile is dying too soon. But if so, why would it not show its explosion?
At a random guess: the second “short” missile is hitting a bomb that has already been destroyed but hasn’t left the screen.
We find this:
function Bomb:processMissile(missile)
if self:killedBy(missile) then
missile.v = 0
self:explode()
end
end
And this:
function Bomb:killedBy(missile)
return rectanglesIntersect(self.pos,3,4, missile.pos,3,4)
end
We aren’t checking to see if the bomb is alive. (We also do not trigger the missile explosion, which may be an issue, though I think I decided not to do that to avoid two explosions at the same point.) So an exploding dead bomb could stop a missile.
function Bomb:explode()
self.pos = self.pos - vec2(2,3)
self.explodeCount = 15
end
And …
function Bomb:draw()
pushStyle()
if self.explodeCount > 0 then
sprite(self.explosion, self.pos.x, self.pos.y)
self.explodeCount = self.explodeCount - 1
if self.explodeCount == 0 then self.alive = false end
else
sprite(self.shapes[self.shape + 1],self.pos.x,self.pos.y)
end
popStyle()
end
We don’t set alive
to false
until explodeCount
is zero. Therefore we need something like this:
function Bomb:processMissile(missile)
if self:isKillable() and self:killedBy(missile) then
missile.v = 0
self:explode()
end
end
With this:
function Bomb:isKillable()
return self.alive and self.explodeCount <= 0
end
I reckon that will make that little problem go away. It’s hard to duplicate but I’ll play a while to gain confidence. Then let’s talk about testing.
I saw some solid hits on bombs, followed by my next shot going past that location. I think we’ve found and fixed that defect. Commit: fixed defect causing shots to stop at dying bomb locations.
Now to sum up including some thoughts on testing.
Could tests have found that defect?
What would a test look like to detect this defect? We’d have to ask ourselves the question, and I think that’s the hard part. What is the question?
Is it “After a bomb has been killed by a missile, can another missile hit it”? It’d be hard to think of that.
Is it “After a bomb has been killed by a missile, can another missile hit the debris”? That, at least, makes a kind of real world sense, but not much in our world. Except that it happened.
It is “After a bomb has been killed, can it be killed again”? That seems like such a silly question that if we thought of it, we’d laugh and toss it out.
What about “When are things killable”? That’s an interesting question and we have some odd code in the system now that is answering that question without expressing it.
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
Runner:soundPlayer():play("killed")
return true
else
return false
end
end
This code answers a different question, but one that happens to be valid: is the Invader alive? Since we immediately mark her not alive if she’s hit, this is a valid test.
But we don’t handle the bombs the same way. There, we don’t mark them as not alive until their explosion is over. Thus our more complex definition of isKillable
, which we just coded up.
For completeness, let’s look at the saucer:
function Saucer:processMissile(missile)
if self.alive and self:isHit(missile) then
self:die()
missile.v = 0
end
end
function Saucer:die()
self.alive = false
local sc = self:score()
self.army:addToScore(sc)
self.textScore = string.format("%d",sc)
self.exploding = 60
Runner:soundPlayer():play("killed")
end
This continues the notion that alive
is reset immediately. So our Bomb is the odd man out.
In a perfect world, we’d bring the Bomb into line with the others, and we might even observe some duplication across the classes and see about removing it. Today we do not live in a perfect world, and I’m not going to attempt that trick.
But what about the testing aspect? If they all followed the rule of becoming not alive immediately, we could at least imagine a test that set each against a missile and checked to see that it had set alive
to false
. That’s a test of game state that I could imagine doing. But I didn’t think of that at the time, at least in part because when I’m just doing one, a test seems redundant when I just set alive
to false
a second ago.
A better person might have tested that. I’m not sure if a better person would close the barn door with a test now, or not. For this morning, the answer for me is “not”. I think what we have here is a design flaw, not having a common way to handle the alive-dead topic, which has resulted in simple code, used somewhat inconsistently, and leading to a situation where tests just don’t come to mind.
In short, I messed up, as one does, but in a way that was hard for me to spot. A pair, or a mob,, might have spotted it. Here, the cat and I did not.
Otherwise …
Otherwise, the morning has gone very nicely. We made the one- and two-player scores appear at top of screen, formatted much like the original game, and we found and fixed a curious little defect of timing. We never got deeply confused, and our fingers never left our hands.
A good morning. And a good morning to you. See you next time.