Let’s press on a bit with our learning level. And, I have an idea that may be a bit difficult to implement. Let’s hope so.

The idea is this: some important messages are displayed, especially in the Learning Level. It would be nice if there were a way to play those messages again, in case they were missed.

When I started the above paragraph, I was thinking that might be difficult, especially because the initial message in the crawl is a property of the level, and the room-entry messages are a property of the tiles in the room. It would be “easy” to turn off the one-shot in a room, but it seemed more difficult to trigger the level message again.

By the time I got to the beginning of the paragraph above, I had a “better idea”, i.e. an idea that may be bad but sounds good to me: let’s just have a way to replay the crawl, whatever has been in it. (Later, we’ll perhaps want to distinguish messages that should be retained and those that should not.)

Now that I’m entering this paragraph, I’m thinking we should implement a new kind of crawl message that is retained. Leave the default messages ephemeral. Provide some button or something that lets you bring up previous crawl messages.

In four paragraphs, just talking about the problem with you, I’ve gone from something that seemed both vague and difficult to something that seems relatively straightforward. I wonder what we can do in a few more paragraphs.

I was thinking to replay the retained crawl messages. Doing that from the beginning would become slower and slower, but perhaps messages would expire. (Probably the original dungeon-level message would not expire: it might contain important clues.) Maybe we should retain just the initial message and two prior retained messages, something like that.

Time to review how this thing works.

Floater and Friends

The class that does the work is named Floater. Yet I call the feature “the crawl”. This is probably not good for my brain. Whatever its name is, it’s pretty autonomous. It subscribes to the EventBus and accepts messages “addTextToCrawl” and “addItemsToCrawl”.

The Floater uses an internal object, Provider, which holds on to a list of items that need to be processed. Once in a while, Floater asks Provider for another item, when the last item that Floater has, scrolls up from the bottom line, leaving a line’s worth of space for a new display.

Items can be textual or commands. Commands are handled inside Provider, and do things like cause health to adjust. This is done to keep screen effects aligned with game-level changes. When a monster hits the princess, it enqueues the actual damage in the Provider, so that the damage happens when the crawl displays the associated message.

Hm. In principle, if someone were to Bus:publish a saved message, it should be displayed again. Let’s glance at how Announcer does its display.

function Announcer:trigger()
    for i,m in ipairs(self:messages()) do
        Bus:publish("addTextToCrawl", self, {text=m})
    end
end

The Announcer has a table of text messages, and issues them, one at a time, using addTextToCrawl on the Bus.

This object amounts to (almost?) exactly what we need, an object that holds on to a table of crawl messages and plays them back on demand.

What if Announcer instances were cached by the Announcer class, and could be replayed on demand?

We’d have to change the dungeon’s level intro method to use Announcer, but that’s no biggie.

This should be easy!

        _:test("Announcer cache", function()
            Announcer:clearCache()
            _:expect(#Announcer:cache()).is(0)
            local a1 = Announcer(msgs)
            local a2 = Announcer(msgs2)
            _:expect(#Announcer:cache()).is(2)
        end)

This little test just checks to be sure we’re caching things. I could check the things, but given the code I wrote to make the test run, I don’t see the need:

local AnnouncerCache = {}

Announcer = class()

function Announcer:init(messageTable)
    self.messageTable = messageTable
    self.sayMessages = true
    table.insert(AnnouncerCache, self)
end

function Announcer:cache()
    return AnnouncerCache
end

function Announcer:clearCache()
    AnnouncerCache = {}
end

Now let’s freehand a method to display all the messages in the cache:

function Announcer:playCache()
    for i,ann in ipairs(AnnouncerCache) do
        ann:repeatMessages()
        Bus:publish("addTextToCrawl", self, {text="---"})
    end
end

I propose a new method repeatMessages, and I propose to put “—” in between each Announcer’s messages.

function Announcer:repeatMessages()
    self.sayMessages = true
    self:trigger()
end

This just sets the one-shot flag and then triggers.

Now I need a way to make this go. I’ll patch it into the ?? button.

function Player:query()
    self:inform(self.tile:nearestContents():query())
    Announcer:playCache()
end

A quick test tells me that I need a clearCache at the beginning of a level. I also don’t like that a query that finds nothing now says “no response”. Let’s change that:

function NullQueryObject:query()
    return ""
end

Now take a look at what happens:

replay

This is working. I notice that the long message from the health isn’t spaced the same as most messages. That’s because of this:

    self.rooms = self:makeRoomsFromXYWH(t, announcements)
    local r2 = self.rooms[2]
    local lootTile = r2:tileAt(self.dungeon, 2,2)
    local loot = Loot(lootTile, "Health", 5,5)
    loot:setMessage("This is a Health Power-up of 5 points.\nStep onto it to receive its benefit.")

The loots expect only a single string message, and I put a newline in this one as a stopgap. I think we want this one-string message to turn into a message array, and to be displayed one line at a time, like other crawl messages. And, if you don’t mind my saying so, we’d like that to be true everywhere. Let’s see how this thing is done.

function Loot:query()
    return self.message
end

The query itself just returns its member variable. Let’s see where it goes. That’s the code we just showed:

function Player:query()
    self:inform(self.tile:nearestContents():query())
    Announcer:playCache()
end

Drilling down:

function Player:inform(message)
    Bus:publish("addTextToCrawl", self, {text=message})
end

Now then. I think we want two things here. One, if the message is a table, assume it’s a table of strings, and loop over it as we do in Announcer. Second, if it’s a string, convert it to a table of one or more messages, splitting on newlines.

A quick internet search finds this:

lines = {}
for s in str:gmatch("[^\r\n]+") do
    table.insert(lines, s)
end

This code matches everything that’s not a return or newline. We won’t see both but it’s harmless to leave it. So, first this:

function Player:splitMessageToTable(str)
    lines = {}
    for s in str:gmatch("[^\r\n]+") do
        table.insert(lines, s)
    end
    return lines
end

function Player:inform(message)
    if type(message) == "string" then
        message = Player:splitMessageToTable(message)
    end
    for i,msg in ipairs(message) do
        Bus:publish("addTextToCrawl", self, {text=msg})
    end
end

function Player:splitMessageToTable(str)
    lines = {}
    for s in str:gmatch("[^\r\n]+") do
        table.insert(lines, s)
    end
    return lines
end

Test to see this working. Works a treat. I think, however, we’d like this to be an open function. So:

function Player:inform(message)
    if type(message) == "string" then
        message = splitStringToTable(message)
    end
    for i,msg in ipairs(message) do
        Bus:publish("addTextToCrawl", self, {text=msg})
    end
end

function splitStringToTable(str)
    lines = {}
    for s in str:gmatch("[^\r\n]+") do
        table.insert(lines, s)
    end
    return lines
end

I’m keeping most of the functions like that in the Main tab, so I’ll move it over there.

Commit: Object query results can be string, with optional newlines, or array of strings without newlines.

It wouldn’t be that much harder to allow a table of strings with optional newlines, but fact is, we presently don’t have a need for the table in this function.

However, we need a new heading for this next thought.

Duplication

We have an odd kind of duplication going on here, between Announcer, query, and the “addTextToCrawl” and “addItemsToCrawl” mesages via EventBus to Floater. We accept tables of lines, and lines with newlines (but not tables of lines with newlines), and the unwinding is taking place in a number of locations.

Let’s review Floater again:

function Floater:init(yOffsetStart, lineSize, lineCount)
    self.provider = Provider("")
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    Bus:subscribe(self,self.addTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.addItemsFromBus, "addItemsToCrawl")
end

function Floater:addItemsFromBus(event, sender, info)
    self:addItems(info)
end

function Floater:addItems(array)
    self.provider:addItems(array)
end

function Floater:addTextToCrawl(event, sender, info)
    self:addItem(CombatRound():display(info.text or ""))
end

function CombatRound:display(aString)
    return OP:display(aString)
end

function OP:display(aString)
    local op = OP("display")
    op.text = aString
    return op
end

This is more convoluted than one would like, because Provider executes commands as well as providing strings to the Floater, as discussed earlier in this article.

Let’s fold in the Floater:addItem, which is unused other than locally.

function Floater:addTextToCrawl(event, sender, info)
    self.provider:addItem(CombatRound():display(info.text or ""))
end

This lets me remove Floater:addItem as it is no longer called. Commit: inline Floater:addItem.

There’s a better way to get what comes back from CombatRound as well. Note:

function CombatRound:display(aString)
    return OP:display(aString)
end

But no. We really only use OP inside CombatRound. Let’s keep it that way.

Look at this:

function Floater:addItemsFromBus(event, sender, info)
    self:addItems(info)
end

function Floater:addItems(array)
    self.provider:addItems(array)
end

I think that no one calls addItems other than the line right above. Let’s inline that.

function Floater:addItemsFromBus(event, sender, info)
    self.provider:addItems(info)
end

function Floater:addTextToCrawl(event, sender, info)
    self.provider:addItem(CombatRound():display(info.text or ""))
end

This works. But now these two methods are only called from the EventBus subscriptions:

function Floater:init(yOffsetStart, lineSize, lineCount)
    self.provider = Provider("")
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
    Bus:subscribe(self,self.addTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.addItemsFromBus, "addItemsToCrawl")
end

-- instance methods

function Floater:addItemsFromBus(event, sender, info)
    self.provider:addItems(info)
end

function Floater:addTextToCrawl(event, sender, info)
    self.provider:addItem(CombatRound():display(info.text or ""))
end

They only send to the provider. Therefore, Provider should subscribe to these two messages, and Floater should not.

I start by pasting over the subscribes:

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
    Bus:subscribe(self,self.addTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.addItemsFromBus, "addItemsToCrawl")
end

Now, I could figure this out, but there’s a way to do it with less thinking. I’ll paste over the two methods formerly in Floater, and make them Provider methods:

function Provider:addItemsFromBus(event, sender, info)
    self.provider:addItems(info)
end

function Provider:addTextToCrawl(event, sender, info)
    self.provider:addItem(CombatRound():display(info.text or ""))
end

function Provider:addItem(item)
    table.insert(self.items, item)
end

function Provider:addItems(array)
    assert(type(array)=="table", "did not get a table in addItems")
    for i,item in ipairs(array) do
        self:addItem(item)
    end
end

Now the first of these just forwards to addItems, so I can change that subscribe and delete the method.

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
    Bus:subscribe(self,self.addTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.addItems, "addItemsToCrawl")
end
--[[
function Provider:addItemsFromBus(event, sender, info)
    self.provider:addItems(info)
end
--]]
function Provider:addTextToCrawl(event, sender, info)
    self:addItem(CombatRound():display(info.text or ""))
end

This should all be working. I’ll test it. One test fails, the game works. The test message is:

13: monster can't enter player tile even if player is dead -- Provider:27: did not get a table in addItems

That’s this:

        _:test("monster can't enter player tile even if player is dead", function()
            local chosenTile
            local runner = Runner
            local monsterTile = Tile:room(10,10,runner)
            local monster = Monster(monsterTile, runner)
            local playerTile = Tile:room(11,10,runner)
            local player = Player(playerTile,runner)
            runner.player = player -- needed because of monster decisions
            chosenTile = monsterTile:validateMoveTo(monster,playerTile)
            _:expect(chosenTile).is(monsterTile)
            player:die()
            chosenTile = monsterTile:validateMoveTo(monster,playerTile)
            _:expect(chosenTile).is(monsterTile)
        end)

Yucch. I don’t see where that call is coming from. Here’s where the message came from:

function Provider:addItems(array)
    assert(type(array)=="table", "did not get a table in addItems")
    for i,item in ipairs(array) do
        self:addItem(item)
    end
end

I think I want to know what happened here. Enhance the message.

function Provider:addItems(array)
    local msg = "did not get a table in addItems, got "..tostring(array)
    assert(type(array)=="table", msg)
    for i,item in ipairs(array) do
        self:addItem(item)
    end
end

The new message:

13: monster can't enter player tile even if player is dead -- Provider:28: did not get a table in addItems, got addItemsToCrawl

OK. My brain says revert, my heart says try to figure this out.

Oh. I can’t remove that method, I have to unwind the three parameters of the publish/subscribe. Reverse that last decision.

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
    Bus:subscribe(self,self.addTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.addItemsFromBus, "addItemsToCrawl")
end

function Provider:addItemsFromBus(event, sender, info)
    self:addItems(info)
end

function Provider:addTextToCrawl(event, sender, info)
    self:addItem(CombatRound():display(info.text or ""))
end

OK. This works. Is anyone outside now calling the lower-level methods in Provider:

function Provider:addItem(item)
    table.insert(self.items, item)
end

function Provider:addItems(array)
    local msg = "did not get a table in addItems, got "..tostring(array)
    assert(type(array)=="table", msg)
    for i,item in ipairs(array) do
        self:addItem(item)
    end
end

Only this:

function Floater:runCrawl(array)
    self.provider:addItems(array)
    self:startCrawl()
end

We do need to start the crawl, but we can use the bus to pass the array:

function Floater:runCrawl(array)
    Bus:publish("addItemsToCrawl", self, array)
    self:startCrawl()
end

Now then. Inside Provider those methods are now private. I could inline them, but I prefer the form. I’ll rename addItem first:

function Provider:addTextToCrawl(event, sender, info)
    self:privAddItem(CombatRound():display(info.text or ""))
end

function Provider:privAddItem(item)
    table.insert(self.items, item)
end

function Provider:addItems(array)
    local msg = "did not get a table in addItems, got "..tostring(array)
    assert(type(array)=="table", msg)
    for i,item in ipairs(array) do
        self:privAddItem(item)
    end
end

Should all be good. Hm, and I could have committed earlier. Let it be for now.

Works. Took me two tries to find the two cases. Commit: moving crawl events to Provider from Floater. Making methods private.

Now I think we should make that addItems private as well.

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
    Bus:subscribe(self,self.privAddTextToCrawl, "addTextToCrawl")
    Bus:subscribe(self,self.privAddItemsFromBus, "addItemsToCrawl")
end

function Provider:privAddItemsFromBus(event, sender, info)
    self:privAddItems(info)
end

function Provider:privAddTextToCrawl(event, sender, info)
    self:privAddItem(CombatRound():display(info.text or ""))
end

function Provider:privAddItem(item)
    table.insert(self.items, item)
end

function Provider:privAddItems(array)
    local msg = "did not get a table in addItems, got "..tostring(array)
    assert(type(array)=="table", msg)
    for i,item in ipairs(array) do
        self:privAddItem(item)
    end
end

There’s not much left in Provider that isn’t marked private, just execute and getItem. I think getItem is actually public.

Oh, before I forget. Commit: identify more private methods in Provider.

Now, execute.

function Provider:privExecute(command)
    local receiver = command.receiver
    local method = command.method
    local arg1 = command.arg1
    local arg2 = command.arg2
    local t = receiver[method](receiver, arg1, arg2)
    return t
end

-- public method

function Provider:getItem()
    if #self.items < 1 then return self.default end
    local item = table.remove(self.items,1)
    if item.op == "display" then 
        return item.text
    elseif item.op == "extern" then
        self:privExecute(item)
    elseif item.op == "op" then
        self:privAddItems(self:execute(item))
    else
        assert(false, "unexpected item in Provider array "..(item.op or "no op"))
    end
    return self:getItem()
end

Now provider has just one public method, getItem. Everything else is internal.

I’m a bit troubled by this:

function Provider:privAddTextToCrawl(event, sender, info)
    self:privAddItem(CombatRound():display(info.text or ""))
end

Since Provider is the interpreter for our little combat language, it can legitimately create an operation internally, saving the weird connection into CombatRound.

What happens when we call CR:display is this:

function CombatRound:display(aString)
    return OP:display(aString)
end

function OP:init(op, receiver, method, arg1, arg2)
    self.op = op
    self.receiver = receiver
    self.method = method
    self.arg1 = arg1
    self.arg2 = arg2
end

function OP:display(aString)
    local op = OP("display")
    op.text = aString
    return op
end

We could perhaps do better. Let’s review getItem again:

function Provider:getItem()
    if #self.items < 1 then return self.default end
    local item = table.remove(self.items,1)
    if item.op == "display" then 
        return item.text
    elseif item.op == "extern" then
        self:privExecute(item)
    elseif item.op == "op" then
        self:privAddItems(self:execute(item))
    else
        assert(false, "unexpected item in Provider array "..(item.op or "no op"))
    end
    return self:getItem()
end

Anything with a field op of “display” and a field text will work here. Let’s try just creating one:

function Provider:privAddTextToCrawl(event, sender, info)
    local item = {op="display", text=info.text}
    self:privAddItem(item)
end

That works a treat, and disconnects Provider from CombatRound, though it still shares the design of opcodes with CombatRound. Of course, anyone else could create a set of opcodes. They just don’t.

We’ve simplified Floater substantially, marked almost everything in Provider as private, and removed a connection to another class.

Commit: Provider computes display operation internally.

Let’s sum up. More than enough play for today.

Summary

We set out to make announced messages repeatable. After thinking of several hard ways to do it, I came upon the idea of having Announcer cache all the messages sent to it.

We have that temporarily wired to the ?? button, but more likely we’ll have a separate History button for this purpose. That remains to be seen.

We have a new requirement, which is that we’ll want to clear the cache at suitable times. I’ve made a note of that.

After implementing our little feature, we moved into “make it right” mode and refactored a few classes to make them neater, resulting in narrowing the public interface of Provider to one method, moving subscriptions from Floater over to Provider, where they went anyway.

Overall the code is a bit nicer. We’re leaving the campground more tidy than we found it. This is the way.

We still need to do the dungeon’s initial crawl message with an Announcer, so that it’ll be recorded for posterity, but that’s a simple change. Again, I’ve made a note.

A fun session, at least for me. And that’s the point.

See you next time!


D2.zip