Dungeon 58--the Sky is Falling
Fortunately, I have programming to distract me. But there’s too much going on in there, too. Also: a rather large mistake.
The FloatingMessage (FM) thing is working well, and the Encounter is generating a little story of the battle as intended. However, the game is still running while the story is scrolling up, and that means that while the Princess and I are reading the battle log, the monsters are still moving and attacking. This does not seem fair to me.
Today’s task, as I see it here at the beginning, will be to stop the monster and player action as soon as an Encounter starts, and to start it again after the message has been scrolled out sufficiently. For values of sufficiently. We might even make it be that the player has to touch the screen to restart the action. We’ll decide that once we get the basic pausing dealt with.
Game play, from the monster side of things, is asynchronous. Monsters each set a random timer, at the expiration of which they move:
function Monster:setMotionTimer(base)
self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end
function Monster:setTimer(action, time, deltaTime)
if not self.runner then return end
local t = time + math.random()*deltaTime
return tween.delay(t, action, self)
end
Presently they move once every second to second and a half. I envision that different monsters will move at different speeds when all is in place.
The monster chooses how to move here:
function Monster:chooseMove()
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
self:setMotionTimer()
end
If the monster has unfortunately slipped its mortal coil, it no longer moves. Otherwise it moves, and sets another motion timer for next time.
Again: this happens asynchronously. Codea calls the timer actions when the timers expire, not during a draw cycle or any other coded event.
So the questions before us are: just what do we want to have happen, and how shall we make it happen? Pretty much the two standard questions for any programming moment.
One thing that would work would be to send every monster a message to stop its timer. Since they each have their own motionTimer
member variable, they could stop it. Then, later, when it’s time, we could send all the monsters another message telling them to start their timers. A second or so later, they’d all start moving again.
Having said that, I rather like the idea. Should I come up with two more ideas to make for a better choice? Sure, why not.
Another thing we could do would be to set a globalish flag in GameRunner, saying “don’t move”, and the chooseMove
function could check the flag and skip over the motion stuff, but go ahead and set the timer running for next time. Sooner or later, the flag goes down and monsters start moving again. This scheme has the drawback that as soon as the flag goes down, some of them might trigger, so there would not be a judicious delay before the action starts.
A third idea would be to centralize all the timers in a single object that could be told to stop them, start them, whatever. This would be good, in that it was centralized, but would have the drawbacks of adding overall complexity to the system and of moving away from a sort of monster autonomy into a kind of monster management scheme. I am sure that monsters do not like to be managed.
I still like the first idea best. It is in fact my third idea, since I’d already been considering the other two, but that one just came to me as I typed.
It happens that GameRunner knows all the monsters. In its createLevel
method, we have this:
self.monsters = self:createThings(Monster,3)
for i,monster in ipairs(self.monsters) do
monster:startAllTimers()
end
Does the Encounter know the runner?
function Encounter:init(attacker, defender, random)
self.attacker = attacker
self.defender = defender
self.random = random or math.random
self.messages = {}
end
It does not. However, both attacker and defender do know the runner. As much as I don’t like to violate the Law of Demeter, in this case, for now, I think we’ll do it.
When an encounter is created, we want to tell GameRunner to stop motion timers:
function Encounter:init(attacker, defender, random)
self.runner = attacker.runner
self.attacker = attacker
self.defender = defender
self.random = random or math.random
self.messages = {}
self.runner:stopMotionTimers()
end
When do we want to restart the timers? Well, that will be up to the FloatingMessage.
This is a bit odd, isn’t it? One object stopping things, and trusting that some other will start them. This isn’t so good.
New plan. FloatingMessage will stop timers when a message is scrolling and start them when no message is scrolling. So …
function Encounter:init(attacker, defender, random)
self.runner = attacker.runner
self.attacker = attacker
self.defender = defender
self.random = random or math.random
self.messages = {}
end
function Encounter:attack()
self:log(self.attacker:name().." attacks "..self.defender:name())
local attackerSpeed = self:rollRandom(self.attacker:speed())
local defenderSpeed = self:rollRandom(self.defender:speed())
if attackerSpeed >= defenderSpeed then
self:firstAttack(self.attacker, self.defender)
else
self:firstAttack(self.defender, self.attacker)
end
self.runner:addMessages(self.messages)
return self.messages
end
We pass through GameRunner to start the messages, and GameRunner has created a FloatingMessage that does know the runner:
function GameRunner:init()
self.tileSize = 64
self.tileCountX = 85 -- if these change, zoomed-out scale
self.tileCountY = 64 -- may also need to be changed.
self.tiles = {}
for x = 1,self.tileCountX+1 do
self.tiles[x] = {}
for y = 1,self.tileCountY+1 do
local tile = Tile:edge(x,y, self)
self:setTile(tile)
end
end
self.floater = FloatingMessage(self)
end
function GameRunner:addMessages(anArray)
self.floater:addMessages(anArray)
end
In FloatingMessage:
function FloatingMessage:addMessages(messages)
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
We know when we’re just starting a new batch of messages. That will be the time to tell GameRunner to stop monster motion:
function FloatingMessage:addMessages(messages)
if (#self.messages == 0) then
self.runner:stopMonsterMotion()
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
We don’t really know that we’ve removed the last message from display. I mentioned yesterday that it just keeps drawing but drawing nothing. The code looks like this:
function FloatingMessage:draw()
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self.messages[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
end
end
Certainly inside that bottom if, if the table is empty, we can start motion again. However, inside the draw function, we should stop incrementing pos and calling increment if there are no table entries. Let’s try this:
function FloatingMessage:draw()
if #self.messages == 0 then return end
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self.messages[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
self.runner:startMonsterMotion()
end
end
end
This is getting a bit long, quite a few balls in the air but I think we’re nearly there.
function GameRunner:startMonsterMotion()
for i,m in ipairs(self.monsters) do
m:setMotionTimer()
end
end
function GameRunner:stopMonsterMotion()
for i,m in ipairs(self.monsters) do
m:stopMotionTimer()
end
end
Set motion timer is in Monster already. We add …
function Monster:stopMotionTimer()
self.motionTimer:stop()
end
I’m somewhat certain that that’s how you stop a tween. Let’s run and see what explodes. We’re well past the time to have stuff running.
Monster:259: attempt to call a nil value (method 'stop')
stack traceback:
Monster:259: in method 'stopMotionTimer'
GameRunner:271: in method 'stopMonsterMotion'
FloatingMessage:15: in method 'addMessages'
GameRunner:22: in method 'addMessages'
GameRunner:68: in method 'createLevel'
Main:15: in function 'setup'
Yes, well that’s not how you do it. How do we do it?
function Monster:stopMotionTimer()
tween.stop(self.motionTimer)
end
Don’t you just hate systems where most things are objects but some are not? Anyway this may do the job.
Well, as far as I can tell, it doesn’t work. But it might be hard to tell, because as soon as the scrolling is over the monster can attack again.
But no. If this works, the battle messages should disappear entirely before the next attack. The monsters do not move during the initial scrolling of the introduction, and only start moving when it vanishes. But thereafter, while battles are scrolling, if I move the princess, they follow her. That should not be happening.
After careful observation, I think what’s happening is this: the monsters do stop moving during the scrolling of the battle report. But as soon as the last line goes away, they are set free and attack again almost immediately, so quickly that the next message seems to come out instantly.
I’m not sure, though, and I’m not sure how to determine for sure what’s going on. I don’t see a test that I can write for this.
I’m going to give the princess vast tracts of health so that I can watch a long battle.
No that’s not what’s happening. I put prints here:
function FloatingMessage:addMessages(messages)
if (#self.messages == 0) then
print("stopping")
self.runner:stopMonsterMotion()
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
print("starting")
self.runner:startMonsterMotion()
end
end
end
After the initial stop/start, for the opening crawl, when a monster attacks the princess, attacks continue, and the messages scroll continuously. The game prints starting and stopping just once, then doesn’t do starting again. I think the monster is attacking because he’s adjacent to the player, not because of a move.
Better check and see how the attack happens.
A print here is revealing:
function Monster:moveTowardAvatar()
print("moving toward princess")
local dxdy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
self.tile = self.tile:legalNeighbor(self,vec2(0,dxdy.y))
else
self.tile = self.tile:legalNeighbor(self,vec2(dxdy.x,0))
end
end
The monster repeatedly reports that it is moving.
Have I saved the timer correctly?
function GameRunner:startMonsterMotion()
for i,m in ipairs(self.monsters) do
m:setMotionTimer()
end
end
function GameRunner:stopMonsterMotion()
for i,m in ipairs(self.monsters) do
m:stopMotionTimer()
end
end
I think setMotionTimer
is supposed to have a parameter:
function Monster:setMotionTimer(base)
self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end
Yes, but it defaults to work anyway. And it does store into the motion timer variable, and the stop function was called:
function Monster:stopMotionTimer()
tween.stop(self.motionTimer)
end
It stops during the initial crawl, though. What’s going on?
I’ve been working since about 0830 and it is 0950 now, so I am well into unproductive. I would do well to revert out and start over. However, the big fool says to press on. I want a bit more information at least.
I put prints in both the stop and start methods in Monster. When I start the program, I see “monster start” 3 times, then “monster stop” 3 times. That’s the initial start and the crawl stop.
Then I see the “starting” message and four “monster start” messages. This is odd, as there should be only three monsters. Thereafter, I see varying numbers of “monster start”, namely two, three, or sometimes four. I never again see a “monster stop”, nor a “stopping” message from the FloatingMessage. This is at least credible if the monsters aren’t stopped, since they’ll flood the FM with battle commentary.
One thing that could go wrong would be if we ever called Monster:setMotionTimer
with one already running: it would store a new one on top of the old, and the old one would still run and trigger at least once. And we’re counting on never seeing it again, because we’re counting on the timers never firing again.
I should revert. But first I want to try something:
function Monster:setMotionTimer(base)
print("monster start")
if self.motionTimer then tween.stop(self.motionTimer) end
self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end
If we’re double-dipping, this will kill the prior timer. Let’s see what we see now.
Still weird. OK, well past time to revert. Revert.
What shall we do today, Brain?
I have an idea: Let’s see if we can make the monsters stop moving while the FloatingMessages are crawling.
Let’s begin by trying a global flag StopMonsters
set to true when we want them to stop, and false (or nil) when we want them to be able to move.
In FM:
function FloatingMessage:addMessages(messages)
StopMonsters = true
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
StopMonsters = false
end
end
end
And just to be sure of things …
function FloatingMessage:draw()
if #self.messages == 0 then return end
pushMatrix()
pushStyle()
fill(255)
fontSize(20)
local pos = self.runner:playerGraphicCenter()
pos = pos + vec2(0,self.yOff)
for i = 1,self:numberToDisplay() do
text(self.messages[i], pos.x, pos.y)
pos = pos - vec2(0,self.lineSize)
end
popStyle()
popMatrix()
self:increment(1)
end
Now in Monster:
function Monster:chooseMove()
if StopMonsters then return end
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
self:setMotionTimer()
end
Hm. This is pretty simple, isn’t it? The global is unfortunate, but let’s see if it works.
It appears that monsters do not move at all. That seems a bit more extreme than I had in mind. Is the release happening?
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
print("Go, monsters!")
StopMonsters = false
end
end
end
I guess for symmetry, I’ll put in the stop message too.
function FloatingMessage:addMessages(messages)
print("Stop, monsters!")
StopMonsters = true
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
Oh, I know what it is. We can’t just exit from chooseMove, we have to at least restart the timer. Stand down from alert.
function Monster:chooseMove()
if not StopMonsters then
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
end
self:setMotionTimer()
end
Now what happens? It works as intended. The battle crawl scrolls fully off and only then is another attack possible. The princess, however, can move during this interval, because we’ve not made her sensitive to the stop order.
I’ll remove my prints and commit this: global StopMonsters stops attacks during battle crawl.
We need to have a little talk …
My carefully chosen scheme for doing this job did not work, and I burned at least 90 minutes on it. The scheme with the global variable worked on the second try and took no more than 20 minutes including a stop to drop off a banana in the freezer, petting the cat, and a visit to a room down the hall.
The global is of course rather messy, but it can readily be moved into GameRunner. It’s still a global state at the game level, but that is arguably what we actually want, so perhaps representing it that way is not unreasonable. We can certainly cover it with a method or two in GameRunner.
Was the approach with turning off the timers and turning them back on inherently flawed, or did I just implement it incorrectly? It’s difficult to be sure. It could even be that tween.stop
doesn’t work right.
The current scheme is pretty darn simple. One point to turn off motion, one to turn it on, in the same object, and one check to see if motion is possible, in the moving objects. (We might have to up that to two checks if we want to freeze the princess. I have an alternative plan for her however.)
Earlier this week, I was telling one of the Friday Zoom Ensemble members that I felt that I should set a ten minute timer, and if it went off twice with no commit having happened, I should just automatically revert. He tried a similar idea and found it useful in keeping focused on small simple steps.
Perhaps I should take my own advice.
Let’s improve this code.
function FloatingMessage:addMessages(messages)
self.runner:stopMonsters()
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
self.runner:startMonsters()
end
end
end
Now in GameRunner:
function GameRunner:startMonsters()
self.stopMonsters = false
end
function GameRunner:stopMonsters()
self.stopMonsters = true
end
And in Monster:
function Monster:chooseMove()
if self.runner:monsterCanMove() then
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
end
self:setMotionTimer()
end
And back in GameRunner:
function GameRunner:monsterCanMove(aMonster)
return not self.stopMonsters
end
I added the parameter just in case we someday … oh that’s just speculation. Remove that.
function GameRunner:monsterCanMove()
return not self.stopMonsters
end
This should work as before. However, I have committed one of my common errors:
FloatingMessage:14: attempt to call a boolean value (method 'stopMonsters')
stack traceback:
FloatingMessage:14: in method 'addMessages'
I keep naming methods the same as member vars.
function GameRunner:monsterCanMove()
return not self.freezeMonsters
end
function GameRunner:startMonsters()
self.freezeMonsters = false
end
function GameRunner:stopMonsters()
self.freezeMonsters = true
end
NOW it should work. And it does work as intended. I do have a couple of broken tests, however:
1: addMessage appends -- FloatingMessage:14: attempt to index a nil value (field 'runner')
The tests don’t feel the need to pass in a runner. The FM now really wants one:
function FloatingMessage:addMessages(messages)
self.runner:stopMonsters()
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
Let’s abstract that call and the start one into methods that check for a runner.
function FloatingMessage:addMessages(messages)
self:stopMonsters()
if (#self.messages == 0) then
self:initForMoving()
end
table.move(messages,1,#messages, #self.messages+1, self.messages)
end
function FloatingMessage:increment(n)
self.yOff = self.yOff + (n or 1)
if self:rawNumberToDisplay() > 4 then
self.yOff = self.yOff - self.lineSize
table.remove(self.messages,1)
if #self.messages == 0 then
self:startMonsters()
end
end
end
function FloatingMessage:startMonsters()
if self.runner then self.runner:startMonsters() end
end
function FloatingMessage:stopMonsters()
if self.runner then self.runner:stopMonsters() end
end
Is that a bit too naff? I’ll allow it for now. Again, all should work. And it does:
Let’s–you guessed it–sum up.
Summing Up
My best idea seemed so reasonable, but 90 minutes of not working should be three times as much as time as I should need to decide I need a new idea. I’m still not sure why it didn’t work, but the changes I needed to make were kind of intricate, and relied on stopping existing timers and on there being no “extra” timers. I do kind of wonder about extra timers. In fact, before we commit, let’s do stop any possible extras:
function Monster:setAnimationTimer()
if self.animationTimer then tween.stop(self.animationTimer) end
self.animationTimer = self:setTimer(self.chooseAnimation, 0.5, 0.05)
end
function Monster:setMotionTimer(base)
if self.motionTimer then tween.stop(self.motionTimer) end
self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end
Commit: monsters stop during battle crawl, under game runner control. code added to stop timers before overwriting them.
The approach with the global worked on the second try, and the error was just that I needed to keep the timers running. That could be done another way, but for now it seems like a reasonable implementation.
Then, folding the global inside GameRunner was also straightforward, though I made one of my standard mistakes (q.v.) along the way. Now monsters just ask whether they can move and GameRunner tells them.
(I suppose we could fix that ask-don’t-tell by having GameRunner just ignore moves from immobile monsters. That’s worth thinking about, as it would move all the monster freezing logic out of monster entirely.)
For now, however, we’re in good shape.
Common error number 357
I have a tendency to name a setter method and its underlying member variable with the same name. Codea’s not OK with that, and I don’t blame it. I also don’t blame myself, since I’m not much into blame per se, but I do not see a good way to avoid making this mistake once in a while, unless I were to start naming my member variables in some strange way.
In the absence of a better idea, I’ll just have to try to be smarter. That seems unlikely to work, so if you have a better idea for me, tweet me up.
Testing?
Another topic is whether I should have tested this code somehow. I honestly don’t think so, primarily because it would require a rather large and complex scenario to test it. I could be wrong about that, since I do have the increment
method set up to allow for more than one increment at a time.
But I would still have to set up a game runner or a mock one. Learning to do a mock one would be interesting. Let’s think about that for next time. I’ve made a note of it.
All in all, a successful morning, but I certainly should have reverted sooner. Should I have realized the current scheme was better? Hard to say, but in retrospect, I think it is.
See you next time!