Space Invaders 64
Let’s take a look at a simple attract mode. Zombies!
I went for chai early, so it’s only 0810 and I’m ready to go. My plan is to look into making the gunner appear, move around and shoot during the pre-game touch to start period, in hopes of attracting people who will send me quarters. Or something.
Once we get started on the feature, we’ll try to stick to it, though as usual when building something new, sometimes we have to make a place for it to stand before we build it. And sometimes we don’t. The advantage to refactoring to make a space for the thing is that it’ll go in much more easily. The advantage to jamming it in is that then we’ll see why we shouldn’t have done it that way, and we’ll see how to recover back to decent code when we need to.
It’s a win-win. Unless I get confused, which I never really like.
I think our user story is something like this:
When the game is in “touch to start” mode, after a game has ended, the invaders keep marching. The invaders do not appear at game startup, just the touch to start indication.
Change the game so that whenever the system is in touch to start mode, the invaders march, and a gunner appears, moves around, and shoots at them. The invaders should drop bombs as usual. Basically the computer is playing the game.
There will surely be lot of details to work out, tuning to be done, and so on. We’ll surely slice thin slices off this story, building it up as we go. That is the way.
When last I thought about this, I was thinking that we needed a new state. I also did some work that got reverted out because of a previously-existing defect. I went down a series of rat holes, shaved many yaks, and ultimately reverted, to fight again another day.
I’m tempted to review that work, back in #60, but it went so very wrong that it might contaminate my currently empty mind, So let’s just see what we can do.
First of all, I’d like to get the game’s initial state to be the same as the state after a game is over. Presently it starts on a blank screen at startup, and otherwise leaves things running with “GAME OVER” and “Touch to Start” displayed.
The game starts because of this code in Main:
function touched(touch)
if Runner and Runner:livesRemaining() ~= 0 then
Player:instance():touched(touch)
else
if touch.state == ENDED then
testsVisible(false)
startGame()
end
end
end
If there’s a Runner and it has lives, we’re playing, and the Player gets the touch. Otherwise, we turn off the test display and start the game.
If we can get the game started with zero lives, right at the beginning, then I think it’ll be about right. The startGame
looks like this:
function startGame()
Runner = GameRunner()
invaderNumber = 1
end
I’m not at all sure what the invaderNumber
is about. But we see that each game starts with a fresh GameRunner. That’s good. What happens there?
function GameRunner:init()
self.lm = LifeManager(3, self.spawn, self.gameOver)
Shield:createShields()
Player:newInstance()
TheArmy = Army()
self.sounder = Sound()
self.line = image(208,1)
for x = 1,208 do
self.line:set(x,1,255, 255, 255)
end
self:resetTimeToUpdate()
self.weaponsTime = 0
end
Basically everything. We create a LifeManager with 3 lives. If we would pass a parameter down there from above, we could start with zero lives.
First, I’ll just put a zero there and see what the game does. Well, nothing, no one calls it. I’ll add a new call in Main:startup:
function setup()
runTests()
parameter.boolean("Cheat", false)
Runner = nil
startGame()
end
Just as I hoped, it shows invaders marching, lives zero, GAME OVER, just like after a game. So let’s pass in a number of lives parameter:
function setup()
runTests()
parameter.boolean("Cheat", false)
Runner = nil
startGame(0)
end
function startGame(numberOfLives)
Runner = GameRunner(numberOfLives)
invaderNumber = 1
end
function touched(touch)
if Runner and Runner:livesRemaining() ~= 0 then
Player:instance():touched(touch)
else
if touch.state == ENDED then
testsVisible(false)
startGame(3)
end
end
end
And …
function GameRunner:init(numberOfLives)
self.lm = LifeManager(numberOfLives, self.spawn, self.gameOver)
Shield:createShields()
Player:newInstance()
TheArmy = Army()
self.sounder = Sound()
self.line = image(208,1)
for x = 1,208 do
self.line:set(x,1,255, 255, 255)
end
self:resetTimeToUpdate()
self.weaponsTime = 0
end
Now we should start up in the GAME OVER state.
However, we explode in the tests, apparently in before
:
_:before(function()
Runner = GameRunner()
Runner:soundPlayer():beSilent()
end)
Right, we tried to create one without the parameter. I’ll change that but let’s also make it safer to create one without:
function GameRunner:init(numberOfLives)
self.lm = LifeManager(numberOfLives or 3, self.spawn, self.gameOver)
Shield:createShields()
...
OK, that’s good, we now start in GAME OVER mode. But there is no actual indication in the program that the game is over. Instead we have things like this:
function Player:draw()
pushMatrix()
pushStyle()
self.missile:draw()
tint(0,255,0)
self:drawingStrategy()
popStyle()
popMatrix()
end
function Player:drawNothing()
end
When the game is over, we set the drawing strategy to `drawNothing. Nifty but not quite what we want now.
I’m going to try something. What happens if, instead of setting to drawNothing
, we set it to drawPlayer
? My guess: it draws the player right where it died.
function Player:manageExplosion()
if self:explosionOver() then self.drawingStrategy = self.drawNothing end
end
The above becomes:
function Player:manageExplosion()
if self:explosionOver() then self.drawingStrategy = self.drawPlayer end
end
Well, yes and no. The player does stay where it was, but of course it also does it whenever it gets hit. And when lives is zero, apparently the aliens stop firing.
That’s controlled by the weaponsAreFree
flag, set by calls to these two methods:
function Army:weaponsFree()
self.weaponsAreFree = true
end
function Army:weaponsHold()
self.weaponsAreFree = false
end
That’s set, when the player is exploding, because the player calls back to GameRunner:
function GameRunner:playerExploding()
TheArmy:weaponsHold()
end
Weapons are freed again here:
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
And weaponsTime
is set:
function GameRunner:setWeaponsDelay()
self.weaponsTime = ElapsedTime + 2
end
And that’s called when we spawn a new player:
function GameRunner:spawn()
Player:instance():spawn()
self:setWeaponsDelay()
end
The decision to spawn may be a critical juncture.
The Player knows he’s dead, but he doesn’t know how many lives he has. After a suitable delay to honor the fallen gunner, Player just requests a new one:
function Player:manageSpawning()
if self.count <= 0 then
Runner:requestSpawn()
end
end
That works like this:
function GameRunner:requestSpawn()
self.lm:next()(self)
end
GameRunner forwards to LifeManager:
function LifeManager:next()
self.life = math.min(self.life+1, #self.lives)
return self:current()
end
That returns the next (or last) entry in the lives table:
function LifeManager:current()
return self.lives[self.life]
end
And the entries are these, passed when we created the LimeManager:
self.lm = LifeManager(numberOfLives or 3, self.spawn, self.gameOver)
So we spawn as many as we have lives, and then we call gameOver
, automatically, which does this:
function GameRunner:gameOver()
return
end
Exactly! It does nothing at all, because by not spawning, we bring the game to a stop, and because there are no lives left, GameRunner displays GAME OVER.
The good news is that we have a convenient gameOver
indication, which is called when lives run out. The not quite so good news is that we have code in a number of places that isn’t checking game state but is instead checking number of lives and perhaps other arcane indicators.
Time Check
We’re an hour in, with some learning but no code other than the start up lives thing. I think that’s good and we’ll keep it.
Commit: GameRunner init to numberOfLives.
I still do not have a definitive plan for how to do this. Here’s what I’m tempted to do.
Imagine that there was a “zombie Player”, that looked like a regular player but instead of paying attention to the touch controls, it wandered around randomly, firing randomly. (I’m not saying where this random behavior comes from just now, but it could be internal to the Player.
Maybe that would be all we needed to get the effect we want. So maybe we implement a zombie mode in the Player. Here’s its init:
function Player:init(pos)
self:setPos(pos)
self.count = 210 -- count down to first player
self.missile = Missile()
self.gunMove = vec2(0,0)
self.explosions = { readImage(asset.playx1), readImage(asset.playx2) }
self.missileCount = 0
self.alive = false
self.drawingStrategy = self.drawNothing
--Player.instance = self
end
Well. We are reminded that there is only one Player. It’s a singleton. Which is fine but means that it needs a separate message to tell it to be in zombie mode or not. That’s a bit troubling but let’s follow this trail.
function Player:init(pos)
self:setPos(pos)
self.zombie = false
self.count = 210 -- count down to first player
self.missile = Missile()
self.gunMove = vec2(0,0)
self.explosions = { readImage(asset.playx1), readImage(asset.playx2) }
self.missileCount = 0
self.alive = false
self.drawingStrategy = self.drawNothing
--Player.instance = self
end
function Player:beZombie()
self.zombie = true
end
function Player:beNormal()
self.zombie = false
end
At this moment nothing has changed. But now in GameRunner … Hmm …
function GameRunner:spawn()
Player:instance():spawn()
self:setWeaponsDelay()
end
This is where we should set the zombie flag. Belay the stuff about beNormal. Do this:
function Player:spawn(zombie, pos)
self.zombie = zombie or false
self.alive = true
self.drawingStrategy = self.drawPlayer
self:setPos(pos)
end
I don’t like that both those parameters can be defaulted but we’ll deal with that in cleanup. Now we should do this:
function GameRunner:spawn(zombie)
Player:instance():spawn(zombie)
self:setWeaponsDelay()
end
function GameRunner:gameOver()
self:spawn(true)
end
Now I think what should happen is that when the game is over (including at startup) a gunner should appear, but be unresponsive to the controls. If he happens to be able to be hit, he should explode and be replaced by another.
He does explode. He also doesn’t vanish. I left that code in there, didn’t I?
function Player:manageExplosion()
if self:explosionOver() then self.drawingStrategy = self.drawNothing end
end
There, that’s better. What actually happens is that if I tilt the iPad I can make the zombie player move, but if I touch the screen anywhere, the game will start. We’re going to want something like “Touch Here to Start”, to allow the attract mode to run.
Now it works as intended, with the zombie player spawning and respawning, until you touch the screen, when a game starts. Let’s see if we can make him move.
function Player:update()
self.missile:update()
if self.alive then
self.pos = self.pos + self.gunMove + vec2(self:effectOfGravity(),0)
self.pos.x = math.max(math.min(self.pos.x,208),0)
end
self:manageCount()
end
Let’s see. We can put in an elseif self.zombie
. What should it do? I think we would like to move left or right a random number of times, something small, on the order of ten to twenty percent of the screen. We’d like it to fire missiles as it goes. Let’s first extract a method to give ourselves a place to work:
function Player:update()
self.missile:update()
self:manageMotion()
self:manageCount()
end
function Player:manageMotion()
if self.alive then
self.pos = self.pos + self.gunMove + vec2(self:effectOfGravity(),0)
self.pos.x = math.max(math.min(self.pos.x,208),0)
end
end
Extract again for normal motion:
function Player:manageMotion()
if self.alive then
self:manageNormalMotion()
end
end
function manageNormalMotion()
self.pos = self.pos + self.gunMove + vec2(self:effectOfGravity(),0)
self.pos.x = math.max(math.min(self.pos.x,208),0)
end
Now for zombie motion:
function Player:manageMotion()
if self.alive then
self:manageNormalMotion()
elseif self.zombie then
self:manageZombieMotion()
end
end
And now we can write a nice compact function for that. I just randomly typed this in:
function manageZombieMotion()
if math.random(10) > 5 then self:fireMissile() end
if self.zombieCount > 0 then
self.zombieCount = self.zombieCount - 1
self.pos.x = self.pos.x + self.zombieDirection
else
self.zombieCount = math.random(10,20)
self.zombieMotion = math.random(-1,1)
end
end
We need to ensure that zombieCount is initialized properly.
function Player:spawn(zombie, pos)
self.zombie = zombie or false
self.zombieCount = 0
self.alive = true
self.drawingStrategy = self.drawPlayer
self:setPos(pos)
end
I expect that this can move beyond the edges but let’s see what it does.
Player:112: attempt to call a nil value (method 'manageNormalMotion')
stack traceback:
Player:112: in method 'manageMotion'
Player:106: in method 'update'
GameRunner:87: in method 'update60ths'
GameRunner:76: in method 'update'
Main:38: in function 'draw'
Hm forgot to say Player, twice:
function Player:manageNormalMotion()
self.pos = self.pos + self.gunMove + vec2(self:effectOfGravity(),0)
self.pos.x = math.max(math.min(self.pos.x,208),0)
end
function Player:manageZombieMotion()
if math.random(10) > 5 then self:fireMissile() end
if self.zombieCount > 0 then
self.zombieCount = self.zombieCount - 1
self.pos.x = self.pos.x + self.zombieDirection
else
self.zombieCount = math.random(10,20)
self.zombieMotion = math.random(-1,1)
end
end
Hm again. It appears but it doesn’t move. Ah. I think it’s probably alive. Remember a few days ago I wanted a three-state thing for just this situation. We’ll force alive to not zombie:
function Player:spawn(zombie, pos)
self.zombie = zombie or false
self.zombieCount = 0
self.alive = not self.zombie
self.drawingStrategy = self.drawPlayer
self:setPos(pos)
end
Player:127: attempt to perform arithmetic on a nil value (field 'zombieDirection')
stack traceback:
Player:127: in method 'manageZombieMotion'
Player:114: in method 'manageMotion'
Player:106: in method 'update'
GameRunner:87: in method 'update60ths'
GameRunner:76: in method 'update'
Main:38: in function 'draw'
Well the good news is we got in there. The bad is that apparently I changed my mind in the middle of naming the variable:
function Player:manageZombieMotion()
if math.random(10) > 5 then self:fireMissile() end
if self.zombieCount > 0 then
self.zombieCount = self.zombieCount - 1
self.pos.x = self.pos.x + self.zombieMotion
else
self.zombieCount = math.random(10,20)
self.zombieMotion = math.random(-1,1)
end
end
I think I’d better initialize that as well:
function Player:spawn(zombie, pos)
self.zombie = zombie or false
self.zombieCount = 0
self.zombieMotion = 0
self.alive = not self.zombie
self.drawingStrategy = self.drawPlayer
self:setPos(pos)
end
The good news is that it moves. The bad news is that, as expected, it can move off screen. And it’s not firing, because:
function Player:fireMissile()
if self.alive and self.missile.v == 0 then
self:unconditionallyFireMissile()
end
end
We need:
function Player:fireMissile()
if (self.alive or self.zombie) and self.missile.v == 0 then
self:unconditionallyFireMissile()
end
end
Check that out. The robotic invader plays better than I do. This is very embarrassing.
Let’s see if the game plays when I touch it.
And it does. Tests are green. Commit: initial attract mode.
And it’s 1000 hours and our two hour session is just about over. Let’s sum up.
33 Summing Up
Overall, that went very smoothly. An hour’s code review and thinking felt long, and we looked in a number pf places where we didn’t want to go. But when we looked at the handling of GAME OVER, it was rather nicely isolated in Player. That’s surprising, in a way, because intuitively I’d expect it to be in GameRunner, but it seems to fit nicely where it is.
The behavior went in nicely, basically all in the Player, with just a tweak to GameRunner to spawn the right kind of Player. But it’s not a perfect setup.
Main is still looking at the number of lives to decide what to do. That’s rather invasive. Ideally only the LifeManager would know that fact. In fact, it doesn’t really know the fact, though it can compute it. So something about the game state should be moved upward. Possibly GameRunner should own the touch and pass it around as needed.
The Player now has two flags, alive
and zombie
, which of course provides for four states, only three of which are useful, alive and not zombie, zombie and not alive, and not zombie and not alive. The “alive-zombie” state is possible and makes no sense. So we need something a bit different there.
Clearly there’s lots of tuning to be done as well. Ideally, perhaps, the zombie player would tend to move toward the center. And there’s no way we can let it go over the edges, that’s right out.
But it went well and except that it plays better than I do, I like the zombie just fine. I hope you do as well.
See you next time!