Dungeon 32
Maybe give the monsters a bit of volition. Maybe place them randomly. Maybe some screen controls. Who knows, I just write this stuff.
In the “Simple Dungeon” sample program, which I’m kind of echoing, the monsters, treasures, exits, and player are placed randomly in the dungeon. The monsters, as far as I’ve examined them, just always try to move toward the player. This does mean that they can get trapped. We can imagine things to do about that, including having them ask to be teleported somewhere else. Monsters are like that.
For now, let’s have them move toward the player if they can. I guess to make this interesting, we’d better place them randomly, rather than right next to the avatar as they are now.
Let’s write “randomRoomTile” on GameRunner. I don’t think I need to TDD this. Let’s see if I am mistaken.
function GameRunner:randomRoomTile()
while true do
tx,ty = math.random(1, self.tileCountX), math.random(1,self.tileCountY)
local tile = self:getTile(tx,ty)
if tile.kind == "room" then return tile end
end
end
The only thing that could possibly go wrong here would be that it would loop forever, never finding a room tile. That’s not going to happen. A belt-and-suspenders approach might put a finite limit on the loop with an error at the end. I’m good with this. I think I’ll create a few monsters at random locations and see what happens.
function GameRunner:createMonsters(n)
self.monsters = {}
for i = 1,n or 1 do
local t = self:randomRoomTile()
table.insert(self.monsters, Monster(t.x,t.y,self))
end
end
I’ll call it with 5.
They’re hard to see, but they’re in there. Commit: five monsters placed randomly.
Glancing at the code, I’m reminded of the time hackery I did yesterday, using os.time etc. A chat on the Codea forum reminded me of the tween delay function, which seems like a much better way to manage this kind of thing. Each object that wants timing can just manage its own delays.
In addition, with five ghosts wandering, I was able to notice that they all change their animation states at the same time. This makes sense, as they were all created within zilliseconds of each other. So let’s randomize the time they use just a bit.
First, I’ll remove the time stuff from GameRunner. Nothing to see here, just a bunch of removed code. Ah, here’s a diff from Working Copy:
Now, when a monster inits, he wants to have a timer for his animation, and a timer for when to move. Presently they have this code:
function Monster:time(aTime)
self:moveIfTime()
self:animateIfTime()
end
function Monster:moveIfTime()
self.move = self.move + 1
if self.move >= 4 then
self.move = 0
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local nx, ny = self.tileX + move.x, self.tileY + move.y
self.runner:moveMeIfPossible(self, nx,ny)
end
end
function Monster:animateIfTime()
self.swap = self.swap + 1
if self.swap == 2 then
self.swap = 0
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
end
end
We’ll remove the time
function, since no one calls it, and rename the other two to … to what. We already have a move
method in our external protocol. That’s what GameRunner calls if a requested move is approved. So we can’t rename moveIfTime
to move
, much as we might like to. Maybe choose
. OK, we’ll go with that.
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
self.swap = 0
self.move = 0
self:timer(self.chooseAnimation, 0.5, 0.05)
self:timer(self.chooseMove, 1.0, 0.5)
end
function Monster:timer(action, time, deltaTime)
local t = time + math.random()*deltaTime
tween.delay(t, action, self)
end
function Monster:chooseAnimation()
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
self:timer(self.chooseAnimation, 0.5, 0.05)
end
function Monster:chooseMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local nx, ny = self.tileX + move.x, self.tileY + move.y
self.runner:moveMeIfPossible(self, nx,ny)
self:timer(self.chooseMove, 1.0, 0.5)
end
I think I’ll rename timer
to setTimer
. That seems better.
There’s duplication of those calls to setTimer
and we do want them always to be the same. Two new little methods:
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
self.swap = 0
self.move = 0
self:setAnimationTimer()
self:setMotionTimer()
end
function Monster:setAnimationTimer()
self:setTimer(self.chooseAnimation, 0.5, 0.05)
end
function Monster:setMotionTimer()
self:setTimer(self.chooseMove, 1.0, 0.5)
end
function Monster:setTimer(action, time, deltaTime)
local t = time + math.random()*deltaTime
tween.delay(t, action, self)
end
function Monster:chooseAnimation()
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
self:setAnimationTimer()
end
function Monster:chooseMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local nx, ny = self.tileX + move.x, self.tileY + move.y
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
OK, that’s nice. No duplication to speak of. Commit: monsters use random tweens for animation and motion.
Now what about giving these guys some purposeful motion? We can ask GameRunner “which way to the princess” and work from that. Let’s look at chooseMove and see what we’d really like.
It seems to me that there are two values of interest in the answer to “which way is the princess”, namely whether we should go up or down, or left or right. (We can’t do both. Why not? Maybe we should allow that for monsters. Maybe later.)
Let’s leave it that there are at most two possible moves toward the princess, toward her in y, and toward her in x. Plus or minue one in either case. We will select which to try, randomly.
I notice that SimpleDungeon doesn’t move the monsters unless they are close to the player. That’s an interesting decision. How about if we move randomly unless we are fairly close to the player and then move toward them?
Let’s code this by intention. First I’ll refactor:
function Monster:chooseMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local nx, ny = self.tileX + move.x, self.tileY + move.y
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
function Monster:chooseMove()
local nx,ny = proposeRandomMove()
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
function Monster:proposeRandomMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
return self.tileX + move.x, self.tileY + move.y
end
Now, I guess, a simple if statement for now:
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = proposeMoveTowardAvatar()
else
nx,ny = proposeRandomMove()
end
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
Now getting close to needing to do some work here …
function Monster:proposeMoveTowardAvatar()
local dx,dy = runner:playerDirection(self.tileX, self.tileY)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
I have in mind here that player direction returns -1,0,+1 according to where the player is relative to the provided x and y. So …
function GameRunner:playerDirection(x,y)
tx,ty = player:position()
return sign(tx), sign(ty)
end
function sign(x)
return (x < 0 and -1) or (x == 0 and 0) or (x > 0 and 1)
end
I have high hopes for this. How will my hopes be dashed?
Monster:28: attempt to call a nil value (global 'proposeRandomMove')
stack traceback:
Monster:28: in field 'callback'
...in pairs(tweens) do
c = c + 1
end
return c
end
This is inside the tween callbac I probably forgot a self somewhere. Indeed:
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = proposeMoveTowardAvatar()
else
nx,ny = proposeRandomMove()
end
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
Why didn’t you mention that? Anyway …
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
GameRunner:141: attempt to index a nil value (global 'player')
stack traceback:
GameRunner:141: in method 'playerDirection'
Monster:47: in method 'proposeMoveTowardAvatar'
Monster:26: in field 'callback'
...in pairs(tweens) do
c = c + 1
end
return c
end
That’ll be wrong.
function GameRunner:playerDirection(x,y)
tx,ty = player:position()
return sign(tx), sign(ty)
end
What language do I think I’m using here, anyway. Needs self:
function GameRunner:playerDirection(x,y)
tx,ty = self.player:position()
return sign(tx), sign(ty)
end
Damn. It’s called avatar
not player. Naming inconsistency. Fix now, but we should normalize this.
function GameRunner:playerDirection(x,y)
tx,ty = self.avatar:position()
return sign(tx), sign(ty)
end
These errors are happening after I move the princess near a ghostie, so they are telling me that we’re trying to do the move toward thing. So that’s good news. Ish.
However:
I got started with these two ghosts near me, and they are not moving. Something isn’t right here. I think we’d like to test this proposal code. Past time, isn’t it?
Looking at this:
function Monster:proposeMoveTowardAvatar()
local dx,dy = runner:playerDirection(self.tileX, self.tileY)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
It seems to me that it can only fail if it gets zero dx and dy. I decided to print dx and dy, and discovered to my surprise that they are always 1,1. Which really says to me that … Ah … this is wrong:
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, nx,ny)
self:setMotionTimer()
end
We’re trying to move to 1,1. We (that is to say, I) forgot to add the proposed move to the current position.
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, self.tileX+nx, self.tileY+ny)
self:setMotionTimer()
end
That explains why we didn’t move. But why was that function returning 1,1 all the time. We do want a test, still.
_:test("player direction", function()
local dx,dy
local runner = GameRunner()
local player = Player(100,100)
runner.avatar = player
dx,dy = runner:playerDirection(101,101)
_:expect(dx).is(-1)
_:expect(dy).is(-1)
end)
Tests failed but also got this odd message:
Main:39: attempt to index a nil value (global 'runner')
stack traceback:
Main:39: in function 'draw'
function draw()
pushMatrix()
if CodeaUnit then showCodeaUnitTests() end
if DisplayToggle then
local x,y = Runner.avatar:tileCoordinates()
local gx,gy = x*Runner.tileSize,y*runner.tileSize
focus(gx,gy, 1)
else
scale(0.25)
end
Runner:draw()
popMatrix()
end
Lower case r. How did that ever work? It turns out that I forgot a local runner
in a test, so the test was defining a global runner
, and I fixed that while writing this new test. Fascinating.
Now I get this:
Monster:47: attempt to index a nil value (global 'runner')
stack traceback:
Monster:47: in method 'proposeMoveTowardAvatar'
Monster:26: in field 'callback'
...in pairs(tweens) do
c = c + 1
end
return c
end
Someone else with the same mistake?
function Monster:proposeMoveTowardAvatar()
local dx,dy = runner:playerDirection(self.tileX, self.tileY)
print(dx,dy)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
Yep. Fascinating again. Now about that test:
9: player direction -- Actual: 1, Expected: -1
Did I forget the subtract? I bet I did.
function GameRunner:playerDirection(x,y)
tx,ty = self.avatar:position()
return sign(tx), sign(ty)
end
Sure did. Let’s see. Which way should the subtract go? tx - x.
function GameRunner:playerDirection(x,y)
tx,ty = self.avatar:position()
return sign(tx-x), sign(ty-y)
end
Test runs now, and I couldn’t resist a live action test:
The test surely isn’t complete. I’ll add a couple more checks just so it doesn’t look odd.
_:test("player direction", function()
local dx,dy
local runner = GameRunner()
local player = Player(100,100)
runner.avatar = player
dx,dy = runner:playerDirection(101,101)
_:expect(dx).is(-1)
_:expect(dy).is(-1)
dx,dy = runner:playerDirection(98,98)
_:expect(dx).is(1)
_:expect(dy).is(1)
end)
That’ll suffice for my porpoises, they’re pretty easy-going.
Commit: monsters chase princess within 10 cells.
Now what about that naming thing? The class is called Player, and the variable is named avatar. Many methods refer to player. None refer to avatar. I’ll change all reverences to avatar
to player
. There’s one in main, a few in test, and a bunch in GameRunner. Not worth displaying the code.
Program runs fine, tests are green. Commit: rename avatar to player throughout.
I’m not sure about these ghosts. The bastards surrounded me:
Summing Up
We’re right at two hours, so thats good. Despite more than my quota of silly mistakes, we got random monster placement, converted timing to use tweens, taught the monsters to chase the princess, and improved the naming just a bit here and there.
Not bad at all.
What about tests? I finally did that one for playerDirection
and it was worth doing, if only because it told me clearly that something was wrong and caused me to think about whether I’d subtracted the values. I hadn’t.
Would other tests have helped? I really don’t think they would have, but I could be wrong. If you can see where a test would have helped, tweet me up.
See you next time!