Just some little things. There may be nothing interesting to see here. One never knows, though, do one?”

Pauses During Crawl

Whenever the crawl is going, player and monster activity stop. In encounters, with their scripted behavior, that’s needed. But I think I’d like to use it for occasions where pausing isn’t necessary. Even in the opening crawl, we could allow the player to start exploring.

OK, how does this thing work? We create a Floater (is that a bad name? Probably. Never mind.):

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self:startCrawl()
end
 
function Floater:startCrawl()
    self.yOff = self.yOffsetStart
    self.buffer = {}
    if self.runner then self.runner:stopMonsters() end
    self:fetchMessage()
end


function Floater:increment(n)
    self.yOff = self.yOff + (n or self:adjustedIncrement())
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
    if #self.buffer == 0 then
        if self.runner then self.runner:startMonsters() end
    end
end

There’s more to it, but that’s where the stop/start action is. Note that we don’t start again until the crawl is empty. So we can’t just conveniently wrap it all up, since the crawl runs asynchronously with everything else.

But we can easily add a defaulted parameter. We’ll default to not allowing movement, though either way may turn out to be more common.

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount, allowMovement)
    self.allowMovement = allowMovement or false
    self.runner = runner
    self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self:startCrawl()
end

Then we can plug in use of the flag:

function Floater:startCrawl()
    self.yOff = self.yOffsetStart
    self.buffer = {}
    if not self.allowMovement and self.runner then self.runner:stopMonsters() end
    self:fetchMessage()
end

function Floater:increment(n)
    self.yOff = self.yOff + (n or self:adjustedIncrement())
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
    if #self.buffer == 0 then
        if not self.allowMovement and self.runner then self.runner:startMonsters() end
    end
end

I’m not loving that not but it seems a better default.

Let’s test now. Everything should be the same, no movement during a crawl. Right. I notice again that it’s faster now, remind me to slow it a bit.

No. I am not a bright man. We don’t need to pass this field in init. We need to pass it in runCrawl, although we are passing in a null message on init.

function Floater:runCrawl(aFunction)
    if coroutine.status(self.provider) == "dead" then
        self.provider = coroutine.create(aFunction)
        self:startCrawl()
    else
        print("crawl ignored while one is running")
    end
end

Let’s give this guy our flag:

function Floater:runCrawl(aFunction, allowMovement)
    if coroutine.status(self.provider) == "dead" then
        self.allowMovement = allowMovement or false
        self.provider = coroutine.create(aFunction)
        self:startCrawl()
    else
        print("crawl ignored while one is running")
    end
end

Now to use it in the initial crawl:

function GameRunner:createLevel(count)
    self:createRandomRooms(count)
    self:connectRooms()
    self:convertEdgesToWalls()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    local tile = self:getTile(vec2(rcx,rcy))
    self.player = Player(tile,self)
    self.monsters = self:createThings(Monster,9)
    for i,monster in ipairs(self.monsters) do
        monster:startAllTimers()
    end
    self.keys = self:createThings(Key,5)
    self:createThings(Chest,5)
    self.buttons = {}
    table.insert(self.buttons, Button("left",100,200, 64,64, asset.builtin.UI.Blue_Slider_Left))
    table.insert(self.buttons, Button("up",200,250, 64,64, asset.builtin.UI.Blue_Slider_Up))
    table.insert(self.buttons, Button("right",300,200, 64,64, asset.builtin.UI.Blue_Slider_Right))
    table.insert(self.buttons, Button("down",200,150, 64,64, asset.builtin.UI.Blue_Slider_Down))
    self:runCrawl(self.initialCrawl, true)
end

Now I expect to be able to move during the initial crawl. However I cannot. That’s discouraging. Better revert.

Why am I reverting rather than just debugging this? Well, because I’ve clearly missed some key fact and I don’t know what it is, plus I put a parameter where it shouldn’t be, plus I don’t like those nots anyway.

Let’s do again.

If we think more deeply about what we want with the crawl, it seems that we want something to happen just before the crawl starts, and something else to happen after it ends. The only case we have now is a call to stopMonsters and startMonsters. Should we provide for other possibilities? And if so, should we do it now? We don’t have another possibility in mind, so my default answer is No, this kind of generalization isn’t appropriate.

Stick with the simple flag:

function Floater:runCrawl(aFunction)
    if coroutine.status(self.provider) == "dead" then
        self.provider = coroutine.create(aFunction)
        self:startCrawl()
    else
        print("crawl ignored while one is running")
    end
end

This seems to be the place to put it, unless we were to make two different entry points. I think the flag will suffice.

function Floater:runCrawl(aFunction, stopAction)
    if coroutine.status(self.provider) == "dead" then
        self.provider = coroutine.create(aFunction)
        self:startCrawl(stopAction)
    else
        print("crawl ignored while one is running")
    end
end

This time we push down the flag as far as it can go.

function Floater:startCrawl()
    self.yOff = self.yOffsetStart
    self.buffer = {}
    if self.runner then self.runner:stopMonsters() end
    self:fetchMessage()
end

That becomes:

function Floater:startCrawl(stopAction)
    self.stopAction = stopAction
    self.yOff = self.yOffsetStart
    self.buffer = {}
    if self.stopAction and self.runner then self.runner:stopMonsters() end
    self:fetchMessage()
end

And we have to start them …

function Floater:increment(n)
    self.yOff = self.yOff + (n or self:adjustedIncrement())
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
    if #self.buffer == 0 then
        if self.stopAction and self.runner then self.runner:startMonsters() end
    end
end

And now we’d best check our calls to runCrawl and startCrawl.

function GameRunner:runCrawl(aFunction, stopAction)
    self.cofloater:runCrawl(aFunction, stopAction)
end

function GameRunner:createLevel(count)
    self:createRandomRooms(count)
    self:connectRooms()
    self:convertEdgesToWalls()
    local r1 = self.rooms[1]
    local rcx,rcy = r1:center()
    local tile = self:getTile(vec2(rcx,rcy))
    self.player = Player(tile,self)
    self.monsters = self:createThings(Monster,9)
    for i,monster in ipairs(self.monsters) do
        monster:startAllTimers()
    end
    self.keys = self:createThings(Key,5)
    self:createThings(Chest,5)
    self.buttons = {}
    table.insert(self.buttons, Button("left",100,200, 64,64, asset.builtin.UI.Blue_Slider_Left))
    table.insert(self.buttons, Button("up",200,250, 64,64, asset.builtin.UI.Blue_Slider_Up))
    table.insert(self.buttons, Button("right",300,200, 64,64, asset.builtin.UI.Blue_Slider_Right))
    table.insert(self.buttons, Button("down",200,150, 64,64, asset.builtin.UI.Blue_Slider_Down))
    self:runCrawl(self.initialCrawl, false)
end

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    --Encounter(self,aPlayer):attack()
    self.runner:runCrawl(createEncounter(self,aPlayer), true)
end

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    --Encounter(self,aMonster):attack()
    self.runner:runCrawl(createEncounter(self,aMonster), true)
end

Again, I expect to be able to move during the initial crawl. And I’m afraid that I won’t be able to.

It’s worse than last time. Now I can’t move even after the crawl stops. WTH?

What have I missed? I’m going to resort to some tracing.

function Floater:startCrawl(stopAction)
    self.stopAction = stopAction
    print("startCrawl ", self.stopAction)
    self.yOff = self.yOffsetStart
    self.buffer = {}
    if self.stopAction and self.runner then print("stopping"); self.runner:stopMonsters() end
    self:fetchMessage()
end

function Floater:increment(n)
    self.yOff = self.yOff + (n or self:adjustedIncrement())
    if self:linesToDisplay() > self.lineCount then
        table.remove(self.buffer,1)
        self.yOff = self.yOff - self.lineSize
    end
    if #self.buffer < self:linesToDisplay() then
        self:fetchMessage()
    end
    if #self.buffer == 0 then
        print("increment ", self.stopAction)
        if self.stopAction and self.runner then print("starting"); self.runner:startMonsters() end
    end
end

Here’s what prints:

startCrawl 	true
stopping
startCrawl 	false
increment 	false

It appears that the initial startCrawl is stopping action but not ending it. That will be the empty message from the initial creation of the Floater. What if we call that with false?

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    self:startCrawl(false)
end

(Possibly we shouldn’t start a null crawl at all. Let’s see what this does.)

OK, now it works as anticipated, but I wonder why the use of true didn’t stop and start immediately. Something about an empty set of messages, I expect. Let’s just not do that and see if things work as intended.

function Floater:init(runner, provider, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    --self.provider = coroutine.create(provider)
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    --self:startCrawl(false)
end

It turns out that we created a provider with the intention of making this work:

function Floater:runCrawl(aFunction, stopAction)
    if coroutine.status(self.provider) == "dead" then
        self.provider = coroutine.create(aFunction)
        self:startCrawl(stopAction)
    else
        print("crawl ignored while one is running")
    end
end

So we “needed” a dead coroutine to get things going. My first fix is this:

function Floater:runCrawl(aFunction, stopAction)
    if self.provider==nil or coroutine.status(self.provider) == "dead" then
        self.provider = coroutine.create(aFunction)
        self:startCrawl(stopAction)
    else
        print("crawl ignored while one is running")
    end
end

And that works. Better, however, is this:

function Floater:runCrawl(aFunction, stopAction)
    if self:okToProceed() then
        self.provider = coroutine.create(aFunction)
        self:startCrawl(stopAction)
    else
        print("crawl ignored while one is running")
    end
end

function Floater:okToProceed()
    return self.provider==nil or coroutine.status(self.provider) == "dead"
end

Still works fine. Remove the commented-out lines and the prints.

function Floater:init(runner, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = nil
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
end

And the creator:

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.cofloater = Floater(self, 50,25,4)
end

There are tests as well. Let’s see what happens. Game works, tests need revision. They both just need this at the beginning, to provide data to test:

        _:test("floater initialize", function()
            local fl = Floater(nil, 50, 25, 4)
            fl:runCrawl(msg)
        
...
        _:test("floater pulls messages appropriately", function()
            local fl = Floater(nil,50,25,4)
            fl:runCrawl(msg)

Tests are green. Commit: crawl now allows optional motion. Initial crawl allows motion.

That took longer than I had expected. Not too surprising, since the whole coroutine thing was only theory to me until I did this one, so one would expect the implementation to be ragged. I think it’s a bit better now.

More?

What else? I did have something in mind. Oh, yes, health.

Some of the monsters are pretty wicked, and I think when we find a health thing, we should get more points. Let’s change it to roll a random addition.

function Health:init(aTile,runner, points)
    self.tile = aTile
    self.runner = runner
    self.points = points or 1
end

function Health:draw(tiny)
    if tiny then return end
    pushStyle()
    spriteMode(CENTER)
    local g = self.tile:graphicCenter()
    sprite(asset.builtin.Planet_Cute.Heart,g.x,g.y+10, 35,60)
    popStyle()
end

function Health:giveHealthPoints()
    self.tile:removeContents(self)
    return self.points
end

We could just initialize them, but let’s make it actually random unless the value is provided. Health items are only found in Chests at present:

function Chest:open()
    if self:isOpen() then return end
    self.pic = self.openPic
    self.tile:addDeferredContents(Health(self.tile, self.runner))
end

We’re not using the points value. I should have never implemented it: it just confused me. Speculation strikes again.

function Health:giveHealthPoints()
    self.tile:removeContents(self)
    return self:randomPoints()
end

function Health:randomPoints()
    return math.random(4,10)
end

I’ll give that a run. Immediately, I roll enough health to overflow the bar graph. We need it to max at 20.

function Player:startActionWithHealth(aHealth)
    self.healthPoints = self.healthPoints + aHealth:giveHealthPoints()
end

Tch tch.

function Player:startActionWithHealth(aHealth)
    self:addHealthPoints(aHealth:giveHealthPoints())
end

function Player:addHealthPoints(points)
    self.healthPoints = math.min(20, self.healthPoints + points)
end

Seems to work as anticipated. I even managed to vanquish a serpent by fighting near a chest and running to it when I got weak.

Commit: health object provides random health points 4-9.

Time for One More?

I’d like for the added health value to create a floater, saying something like “+5 health!!” when we use it. That can be done in two ways.

Recall that Floater expects to receive a function that it puts into a coroutine, to return the messages it wants.

For example, the initial crawl:

function GameRunner:initialCrawl()
    coroutine.yield("Welcome to the Dungeon.")
    coroutine.yield("Here you will find great adventure,")
    coroutine.yield("fearsome monsters, and fantastic rewards.")
    coroutine.yield("Some of you may die, but I am willing")
    coroutine.yield("to make that sacrifice.")
end

We run the crawl like this:

...
    self:runCrawl(self.initialCrawl, false)
...

So the direct way to do this is this:

function Player:addHealthPoints(points)
    self.runner:runCrawl(healthFloater, false)
    self.healthPoints = math.min(20, self.healthPoints + points)
end

function healthFloater(message)
    coroutine.yield(message)
end

Well, then again, maybe it isn’t. Because the message doesn’t come out. Instead I get the message “crawl ignored while one is running” when I get into a battle after grabbing a health.

So that function must not be quite the thing.

Sheesh, of course. It doesn’t have a message, and it kind of can’t, at least not like that. We have to be much more crafty. This is likely to get nasty, and it’s nearly time for supper.

Ah. I guessed right the first time:

function Player:addHealthPoints(points)
    msg = string.format("+%d Health!!", points)
    local f = function()
        coroutine.yield(msg)
    end
    self.runner:runCrawl(f, false)
    self.healthPoints = math.min(20, self.healthPoints + points)
end

This works just as intended.

health movie

This’ll be a good place to close.

Summing Up

I mentioned two ways to do the floater. The more general thing would be to have a generic way to create a text message, or a collection of them, and generate a crawl from them. The next time we need to do a simple crawl, we’ll figure that out. For now, we’re good.

Overall, this was a bit fast and loose, but things went OK. I was doing some fairly tricky things, between the asynchronous stop/start logic and the coroutine for the crawl, and all that. The health addition was interesting. It could surely have been done with TDD, and if I had, I’d probably have thought to test a total bigger than 20. But I got lucky and saw the problem happen right away.

It’s tricky, when doing things without TDD, to suddenly drop into TDD when the situation better supports it.

And maybe I coulda shoulda TDD’d the other things.

I don’t know. I”m just a programmer, wandering in the wilderness, trying to figure out what to do and how to improve.

Join me next time!

D2.zip