I think today I’ll work on room announcements. I have a partial idea.

The story idea is this:

The first time the player enters a room, a message scrolls in the crawl. This message might be used to give instructions in the learning level, or to offer ominous creepy messages during exploration. The message should appear only once, on first entrance into the room.

The “big” question here is how we know that we have entered the room, and that it’s the first time. A room can have more than one path in and out. In general, the system doesn’t pay much attention to room identification, although I believe each room tile knows its room number, which is the room’s index in the largely unused rooms collection in GameRunner. Let’s brainstorm a few ways this story might be done.

Radar
We could center a tile in the room that somehow detects the player’s presence and emits the message. A difficulty with this is that rooms are of varying size and not square, so the radar would have to be fairly sophisticated.
Illumination
Room tiles are marked when first seen by the player, so that they will thereafter show up on the small map. An issue with this is that the tiles right in line with the player are illuminated before she gets to the room.
Fence
This is my favorite idea so far. We create a single message object containing the one-shot message. When we create the room, we put the same object in the contents of all the tiles on the border of the room (or in every tile, no harm done). Some of those tiles will be entrances: most will not. When the player steps onto any of those tiles, the usual tile interaction logic triggers the message and flips the one-time switch.

One issue with this would be that every step would then run the tile contents logic, which seems inefficient. Putting the message objects just into the borders would reduce this concern.

Another issue is that objects aren’t supposed to be in more than one tile, so there could be problems implementing this. Generally dungeon objects know their tile, but they don’t really do much with it.

Doors
A more advanced approach would require us to implement a capability that we can see coming anyway: identifying where the entrances to the room actually are. We could do that by causing hallway carving to mark the room tiles where they cross into the room.

Wow, lots of alternatives. I still think I prefer the Fence idea.

Small Steps

Our ideal practice is to work in very small steps that can be committed frequently, leaving the product shippable at all times. To that end, let’s first create an announcement object and make it work, then deal with installing them.

I think this object is probably a kind of Decor, don’t you? Or maybe a Loot, but I think Decor is going to outlast Loot over the long haul. Still, we should check to see which one will be easier to adapt.

Decor:init is a bit troubling:

function Decor:init(tile, item, kind)
    self.kind = kind or Decor:randomKind()
    self.sprite = DecorSprites[self.kind]
    if not self.sprite then
        self.kind = "Skeleton2"
        self.sprite = DecorSprites[self.kind]
    end
    self.item = item
    self.tile = nil -- tile needed for TileArbiter and move interaction
    tile:moveObject(self)
    self.scaleX = ScaleX[math.random(1,2)]
    local dt = {self.doNothing, self.doNothing, self.castLethargy, self.castWeakness}
    self.danger = dt[math.random(1,#dt)]
end

There’s a lot of built-in logic there, deciding whether to cast lethargy or weakness. We could get around that. Let’s check Loot.

Loot is a bit simpler:

function Loot:init(tile, kind, min, max)
    self.tile = nil
    self.kind = kind
    self.icon = self:getIcon(self.kind)
    self.min = min
    self.max = max
    if tile then tile:moveObject(self) end
end

function Loot:actionWith(aPlayer)
    self.tile:removeContents(self)
    if self.kind == "Pathfinder" then
        self:addPathfinderToInventory(aPlayer)
    elseif self.kind == "Antidote" then
        self:addAntidoteToInventory(aPlayer)
    else
        aPlayer:addPoints(self.kind, math.random(self.min, self.max))
    end
end

We could extend that actionWith to create a kind of message Loot. That looks more fruitful to me. If someday we do get rid of the low-hanging Loot, we can still use it for our announcer.

I try this:

function Loot:actionWith(aPlayer)
    self.tile:removeContents(self)
    if self.kind == "Pathfinder" then
        self:addPathfinderToInventory(aPlayer)
    elseif self.kind == "Antidote" then
        self:addAntidoteToInventory(aPlayer)
    elseif self.kind == "Announcer" then
        Bus:publish("addTextToCrawl", self, {text="You have entered a dangeous and interesting room!"})
    else
        aPlayer:addPoints(self.kind, math.random(self.min, self.max))
    end
end

And I’ve given the announcer an icon for now:

local LootIcons = {Strength="blue_pack", Health="red_vial", Speed="green_flask",
Pathfinder="blue_jar", Antidote="red_vase", Announcer="gold_bag"}

Now let’s place one and step on it. Where’s that code for Lever, we’ll put it near that.

function GameRunner:placeLever()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    local leverTile = tile:getNeighbor(vec2(0,2))
    local lever = Lever(leverTile, "Lever 1")
end

OK …

function GameRunner:placeLever()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    local leverTile = tile:getNeighbor(vec2(0,2))
    local lever = Lever(leverTile, "Lever 1")
    local announcerTile = tile:getNeighbor(vec2(0,-2))
    Loot(announcerTile, "Announcer", 0,0)
end

Let’s see what we get.

announcer

So that worked as advertised. However, I’m starting to feel badly about using a Loot for this purpose. The Loot has mechanism that doesn’t apply, and maybe you noticed that when I tried to move onto the Announcer before it triggered, I was kept out.

I think this should be a new, small, object. Should it be Announcer? Or should it be, perhaps, Fence? I can imagine it being useful for other purposes, including acting as a door.

But YAGNI. We build for today, not for the possible future. Habitable code today will support tomorrow’s need, and tomorrow may be different from how we imagine it to me.

So, we’ll consider what we’ve done here to be a spike and revert it.

Announcer Class

I’m not at all sure how to TDD this thing, but let’s at least try. It is almost certain to pay off, and it’s a brand new class so we can make it pretty easy to test.

New Class: Announcer. Paste in the default testing code.

-- Announcer
-- RJ 20210519

function testAnnouncer()
    CodeaUnit.detailed = false
    
    _:describe("Announcer", function()
        
        _:before(function()
        end)
        
        _:after(function()
        end)
        
        _:test("First Test", function()
            _:expect(2).is(2)
        end)
        
    end)
end

Announcer = class()

function Announcer:init()
end

function Announcer:draw()
end

OK, what do we know? Well, we want to put a single copy of this object into many tiles. We probably don’t want it to know the tiles. We do want it to know its message. And I think we’ll want a debug drawing method for now, but in general it’s invisible. And it has to appear in the TileArbiter tables, so that it can be interacted with.

I’m not sure what to test. I guess some kind of method that returns the message array once and never again.

        _:test("Create", function()
            local ann = Announcer(msgs)
            _:expect(ann:messages()).is(msgs)
            _:expect(ann:messages()).is(nil)
        end)

local msg = {"This is the mysterious level called", "Soft Wind Over Beijing,",
    "where nothing bad will happen to you", "unless you happen to be a princess.", "Welcome!" }
}

function Announcer:init(messageTable)
    self.messageTable = messageTable
    self.sayMessages = true
end

function Announcer:messages()
    if self.sayMessages then
        self.sayMessages = false
        return self.messageTable
    else
        return nil
    end
end

Test passes, whee!

Let’s make a TileArbiter entry.

    t[Announcer] = {}
    t[Announcer][Monster] = {moveTo=TileArbiter.acceptMove}
    t[Announcer][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithAnnouncer}

We have no good way to test these.

Now let’s give this baby a way to draw itself.

At this point I realize that I have no knowledge of where the object is, so I can’t draw it. How does Tile:draw work?

Ah, super, here’s the relevant method:

function Tile:drawContents(tiny)
    if not self.currentlyVisible then return end
    local center = self:graphicCenter()
    for k,c in pairs(self.contents) do
        c:draw(tiny, center)
    end
end

We can deal with that:

function Announcer:draw(ignored, center)
    pushMatrix()
    pushStyle()
    textMode(CENTER)
    textSize(40)
    text("?", center.x, center.y)
    popStyle()
    popMatrix()
end

Now let’s plant one:

Two bad things happened.

First, nothing displayed. Second, when I moved onto the tile with the Announcer in it, I got this message:

TileArbiter:17: attempt to call a nil value (method 'getTile')
stack traceback:
	TileArbiter:17: in field 'moveTo'
	TileArbiter:28: in method 'moveTo'
	Tile:109: in method 'attemptedEntranceBy'
	Tile:381: in function <Tile:379>
	(...tail calls...)
	Player:200: in method 'moveBy'
	Player:144: in method 'executeKey'
	Player:194: in method 'keyPress'
	GameRunner:398: in method 'keyPress'
	Main:68: in function 'keyboard'

Clearly we expect a tile to move to. And I’m not sure why nothing displays.

Drawing is easily fixed. We need a fill color, and I should have said fontSize instead of textSize:

function Announcer:draw(ignored, center)
    pushMatrix()
    pushStyle()
    textMode(CENTER)
    fontSize(40)
    fill(255)
    text("?", center.x, center.y)
    popStyle()
    popMatrix()
end

Now a big ? shows in the tile where the Announcer is. So that’s nice. Now about the tile. Normally, tile contents know the tile. And, for some reason, we ask them what their tile is when they want to accept the move. Let’s explore that.

function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
    local ta
    local acceptedTile
    local accepted = false
    local residents = 0
    for k,residentEntity in pairs(self.contents) do
        residents = residents + 1
        ta = TileArbiter(residentEntity,enteringEntity)
        acceptedTile = ta:moveTo()
        accepted = accepted or acceptedTile==self
    end
    if residents == 0 or accepted then
        return self
    else
        return oldRoom
    end
end

The moveTo can, in principle, return a different tile than the one you’re on. I suppose a particular item could teleport you somewhere. Let’s trace a bit further: I’m tempted to pass in the current tile as the default to moveTo:

function TileArbiter:moveTo()
    local entry = self:tableEntry(self.resident,self.mover)
    local action = entry.action
    if action then action(self.mover,self.resident) end
    local result = entry.moveTo(self)
    return result
end

In this code, we’ll fetch acceptMove from the entry and I see no reason why we can’t pass the resident’s tile all the way back down:

function Tile:attemptedEntranceBy(enteringEntity, oldRoom)
    local ta
    local acceptedTile
    local accepted = false
    local residents = 0
    for k,residentEntity in pairs(self.contents) do
        residents = residents + 1
        ta = TileArbiter(residentEntity,enteringEntity)
        acceptedTile = ta:moveTo(self)
        accepted = accepted or acceptedTile==self
    end
    if residents == 0 or accepted then
        return self
    else
        return oldRoom
    end
end

function TileArbiter:moveTo(defaultTile)
    local entry = self:tableEntry(self.resident,self.mover)
    local action = entry.action
    if action then action(self.mover,self.resident) end
    local result = entry.moveTo(self, defaultTile)
    return result
end

function TileArbiter:acceptMove(defaultTile)
    return self.resident:getTile(defaultTile)
end

function Announcer:getTile(defaultTile)
    return defaultTile
end

Test. We don’t crash, and we can move onto the ? tile, but I expected to crash because Player doesn’t understand the action message. Ah: I have a safety valve here:

function TileArbiter:moveTo(defaultTile)
    local entry = self:tableEntry(self.resident,self.mover)
    local action = entry.action
    if action then action(self.mover,self.resident) end
    local result = entry.moveTo(self, defaultTile)
    return result
end

If the entity doesn’t have the action we don’t trigger it. Makes sense. Provide the method:

function Player:startActionWithAnnouncer(announcer)
    announcer:trigger()
end

And the Announcer:

function Announcer:messages()
    if self.sayMessages then
        self.sayMessages = false
        return self.messageTable
    else
        return {}
    end
end

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

I changed the messages method to return an empty array, to make trigger simpler.

Let’s see this baby work.

announcer works

And it does. Commit: Announcer object contains one-shot array of messages, emits when stepped on. Currently displays a ?.

Nice. It’s about 1000 hours, and a nice place to stop and sum up. We’ll leave the sample message in the system, since we can consider that we’re shipping betas at the moment.

Summary

We started by trying to make our Announcer a kind of Loot, but then realized that its behavior was too unlike that of a Loot, and that a small separate class made more sense. A quick revert and a new class, and there we are.

We really just have one simple test, which had to change, by the way, when I changed the messages method to return an empty array:

        _:test("Create", function()
            local ann = Announcer(msgs)
            _:expect(ann:messages()).is(msgs)
            _:expect(#ann:messages()).is(0)
        end)

Our object is strange, in that we plan to have a single object appearing in multiple tiles. This actually seems like it could be good for other purposes, perhaps triggering traps or the like. We’ll see.

We did something a bit odd to make that work. We changed TileArbiter to pass down to the moveTo entry, the tile that the entry is “in”, according to TileArbiter. This means that we could put the same Announcer in an number of cells and it would work.

Should we try that? Let’s.

function GameRunner:placeLever()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    local leverTile = tile:getNeighbor(vec2(0,2))
    local lever = Lever(leverTile, "Lever 1")
    local annTile = tile:getNeighbor(vec2(0,-2))
    local ann = Announcer({"This is the mysterious level called", "Soft Wind Over Beijing,",
    "where nothing bad will happen to you", "unless you happen to be a princess.", "Welcome!" })
    annTile:addDeferredContents(ann)
    annTile = tile:getNeighbor(vec2(1,-2))
    annTile:addDeferredContents(ann)
    annTile = tile:getNeighbor(vec2(-1,-2))
    annTile:addDeferredContents(ann)
end

Now we add the same announcer, ann to three tiles:

three ?

And, sure enough, when we step on any one of them, the messages come out, and after that, stepping on any of them has no more effect.

Let’s commit that: Three identical announcers in player room.

So, as I was saying, we can place as many identical announcers as we like, and they all act like the individual they are, triggering the first time stepped on, no matter which tile is involved in the triggering.

It may seem odd to pass the content object’s tile down to it, but in fact if we did that commonly, we could probably change all the dungeon objects so that they don’t have to have a tile instance, and that would be a structural simplification.

You may recall that we’ve talked about this before. The general case is that either a given object needs to know some other element, or to be passed the element whenever it needs to refer to it. In this program, we have often chosen to have the objects know their parents, and that comes with a cost in complexity, especially testing.

So I kind of like this change, and I’ve made a note to look into whether we could use it more generally and unhook Tile contents from knowing their tile.

It comes to me that perhaps we should have static contents, like loot and chests, and dynamic contents, like monsters and players that move through tiles. We do want the moving ones there, that’s how we detect the opportunity for combat. Although one can imagine another way, just checking the contents in TileArbiter works rather nicely.

Which is worthy of note. We just added two entries to TileArbiter, and monsters and player can now interact with the new Announcer. That’s rather nice. TileArbiter is holding up well.

So. A good first implementation of the ability to have a room announce something when you enter it. We’ll complete the implementation tomorrow, and then maybe even use it. I hope so: that’s the point of building a feature.

See you then!


D2.zip