Dungeon 185
Turns out we do a number of small but nice, and somewhat interesting, things. Not much stumbling, sorry. I know you love to see me mess up.
At this moment, I haven’t much of an idea about what we’l do this lovely overcast Monday morning. I guess I’ll have to go back and edit the article blurb later on today. That’ll be a change from the normal mode of stating my plan and then doing Something Entirely Different.
We do have some leftovers from our work with the Announcer and the “History” capability that replays important messages in the crawl. We can start on that and then see what turns up.
There are still larger issues, of course, including some work on our “Making App”, the toolset we use to build dungeons, And we should do enough work on the Learning Level to demonstrate the extent to which it’s a solved problem.
I have two notes relating to our announcement work:
- Make sure the initial dungeon message is cached;
- Make sure that all levels clear the Announcer cache;
I also have a small concern, which is whether we can start the crawl without any messages in it. Presently we start it like this:
...
self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
...
function Floater:runCrawl(array)
Bus:publish("addItemsToCrawl", self, array)
self:startCrawl()
end
function Floater:startCrawl()
self.yOff = self.yOffsetStart
self.buffer = {}
self:fetchMessage()
end
It would be pleasant if we could just start it without setting up a message first. Otherwise there’s a kind of an asymmetry here, which is that the first message has to start the crawl and (it could be true) you have to have a message to start it.
I think the latter is not the case. I think it’ll run fine if we just start it. We can test that by commenting out the Bus:publish
. And in fact it works just fine. Good to know.
People with far too little to think about may recall that the Floater is always displaying something, it’s just that when there’s nothing to say, it’s displaying empty lines, scrolling up and up with no one to admire them.
There’s another issue. As proud as I am of realizing that the Announcer display was a convenient point for storing messages for replay, there’s a differet kind of asymmetry here. Announcer is an object, intended to be placed where you’ll step on it, whereupon it will make an announcement. But we also have the ability for any object to put something in the crawl, via publish. So you could argue that caching messages for replay should be a property of the message, not the particular way it was emitted.
Right now we don’t have a need for that, but in a moment, the concern will be more clear, I think.
Announcing the Dungeon Level
We want the dungeon’s introductory message to be cached. Right now that means we just have to use an Announcer to trigger it. That should be easy enough.
Before we’re done here, I want to separate the startCrawl
out, but let’s do one thing at a time. The new Announcer first. This method “just” needs to use the Announcer:
function Floater:runCrawl(array)
Bus:publish("addItemsToCrawl", self, array)
self:startCrawl()
end
Hm. That doesn’t seem right, does it? We shouldn’t be asking the Floater to create an Announcer and use it. We should do that in GameRunner.
For now, I’ll remove that Bus message and leave runCrawl in place, because I think there are a couple of calls to it. When we change the protocol, in a moment, we’ll deal with all of them, but right now it’s too many balls to juggle. One small step at a time.
function Floater:runCrawl(array)
self:startCrawl()
end
This is kind of useless now, but it’ll make sure no one is surprised by the method disappearing. Now in GameRunner we have this:
self.cofloater:runCrawl(self:initialCrawl(self.dungeonLevel))
We’ll turn that into this:
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
It turns out that initialCrawl
goes through a bunch of CombatRound foofaraw, and Announcer just wants the array of messages.
I put the change above into the Learning Level, and the message comes out OK. Let me do it in the regular levels, and then I’ll make a movie for you. That’s the work of a copy-paste (we’ll discuss that) and here we go:
That works, if not as intended, as implemented. We’re presently using the ?? button to trigger the replay of the cache, which means that we get more than we bargained for when we query the health object. But, as I say, that’s what we set up as our test bed.
We can commit now, but first I think we can remove runCrawl
entirely. I’ll check.
Meh. Unfortunately, there are a couple of tests that use runCrawl
. They are, unfortunately, fairly important, as they check that the crawl initialized properly, and that it processes messages correctly.
There’s something odd, though: they are not failing, even though I removed the publication from the runCrawl
method.
Ah. This is interesting: they are failing, but my code to display test errors isn’t displaying the red stuff. Did I comment that out for some purpose?
Oh that’s cute. Here’s how I check to see whether to display the error info:
if Console:find("[1-9] Failed") or Console:find("[1-9] Ignored") then
It turns out that ten tests fail. So the line is “10 Failed” and the match doesn’t match.
Right now I have more than enough balls in the air. I need to get those tests back to running. Is putting the Bus:publish back sufficient, or have these guys been sneakily broken for longer?
Putting it back makes the tests pass. Let’s rename runCrawl
to testOnlyRunCrawl
. That works.
Commit: initial dungeon message uses Announcer and is thus cached for replay.
Now let’s check that if and improve it. I thought I could just negate the if and check for “0 Ignored, 0 Failed”, but there could be many such lines in the output: we’re looking at all the tests’ reports. So we want to detect whether there is a line with a number other than zero in front of Ignored or Failed.
Lua does not allow the alternative style of regex. The full implementation of regex is more lines of code than the sum of all the standard Lua libraries. Let’s try this.
function draw()
local showingTests = false
--speakCodeaUnitTests()
if CodeaUnit and CodeaTestsVisible then
if Console:find("[1-9] Failed")
or Console:find("[1-9] Ignored")
or Console:find("[1-9][0-9] Failed")
or Console:find("[1-9][0-9] Ignored") then
showingTests = true
showCodeaUnitTests()
end
end
if not showingTests then
background(0)
Runner:draw()
end
end
Nasty, but works. I had to put a corresponding change into the showCodeaUnitTests
function to set the red or yellow color.
Commit: fix problem causing test display to fail on error counts divisible by 10.
What Now?
We’ve got the initial message being done by Announcer, and therefore cached. So that’s good. But I noticed something. The text sometimes comes out behind things in the dungeon, specifically monsters. This means we have an issue in the drawing order, since OpenGL’s drawing level doesn’t suffice to handle our concerns. We need to draw things in back to front order.
function GameRunner:draw()
font("Optima-BoldItalic")
self:drawLargeMap()
self:drawTinyMapOnTopOfLargeMap()
self:drawMapContents()
self:drawButtons()
self:drawMessages()
self:drawInventory()
self:drawPlayerOnBothMaps()
self:drawMonstersOnSomeMaps()
end
Well, there’s yer problem right there.
function GameRunner:draw()
font("Optima-BoldItalic")
self:drawLargeMap()
self:drawTinyMapOnTopOfLargeMap()
self:drawMapContents()
self:drawButtons()
self:drawInventory()
self:drawPlayerOnBothMaps()
self:drawMonstersOnSomeMaps()
self:drawMessages()
end
Commit: we have come to prefer messages over monsters.
We aren’t clearing the Announcer cache everywhere that it needs to be cleared, and we have tons of duplication between our three level-creating methods. I apologize for displaying them all but we need to see the duplication and lack thereof:
function GameRunner:createLearningLevel()
Announcer:clearCache()
self.dungeonLevel = 99
TileLock = false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
self:createLearningRooms()
self:connectLearningRooms()
dungeon:convertEdgesToWalls()
self.monsters = Monsters()
self:placePlayerInRoom1()
self:placeWayDown() -- needs to be fixed room
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
self:startTimers()
self.playerCanMove = true
TileLock = true
end
function GameRunner:createLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
TileLock=false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
self.rooms = dungeon:createRandomRooms(count)
dungeon:connectRooms(self.rooms)
dungeon:convertEdgesToWalls()
self:placePlayerInRoom1()
self:placeWayDown()
self:placeSpikes(5)
self:placeLever()
self:setupMonsters(6)
self.keys = self:createThings(Key,5)
self:createThings(Chest,5)
self:createLoots(10)
self:createDecor(30)
self:createButtons()
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
self:startTimers()
self.playerCanMove = true
TileLock = true
end
function GameRunner:createTestLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
TileLock=false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
self.rooms = dungeon:createRandomRooms(count)
dungeon:connectRooms(self.rooms)
dungeon:convertEdgesToWalls()
--self:placePlayerInRoom1()
--self:placeWayDown()
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
self.monsters = Monsters()
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
--self.cofloater:testOnlyRunCrawl(self:initialCrawl(self.dungeonLevel))
--self:startTimers()
self.playerCanMove = true
TileLock = true
end
Two of them are missing the clearing of Announcer cache. I’ll add that to them, making them even more alike.
Looking at these three methods, we can sort of see that there are three “phases” to creating a level. There’s a preparation phase that is common to all, there’s a custom phase in the middle that’s unique to the kind of dungeon it is, and a final phase that is again common to all the levels, getting them started up and running.
I’m thinking about the future here, always a dangerous things to do. I’m sure we want dungeon creation pulled out of GameRunner, and at this moment I’m thinking about how that might go. I’m envisioning some general creation thing that “has” a specialized creation thing. The general one controls the phases, and asks the specialized ones to do their thing at appropriate moments. It is tempting to start to do that.
However, I am both too smart to do that, and not smart enough to do it. It’s a larger change than I’m prepared to make all at once. Instead, I’m going to remove duplication for a while.
I’m going to try a trick that I learned from the great Kent Beck ages ago: make things look more alike, then remove the duplication. What we do here won’t be as amazing as the thing I saw him do, but it should help us nonetheless.
First, I’m just going to make a simple lexical change. I’m going to put a blank line between what I think the phases might be.
In testing before showing you what I’ve done, I noticed that if I switch between a normal level and the learning level while the crawl is running, it’s not cleared. That’ll need addressing.
Here’s what I’ve done to the level creation methods, trying to break out the similarities and differences, and putting temporary comments in front of them to help me keep track of what I’m about.
function GameRunner:createLearningLevel()
self.dungeonLevel = 99
-- prepare
TileLock = false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
-- customize rooms and connections
self:createLearningRooms()
self:connectLearningRooms()
-- paint dungeon correctly
dungeon:convertEdgesToWalls()
-- ready for monsters
self.monsters = Monsters()
self:placePlayerInRoom1()
-- customize contents
self:placeWayDown() -- needs to be fixed room
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
-- prepare crawl
Announcer:clearCache()
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
self:startTimers()
-- finalize level
self.playerCanMove = true
TileLock = true
end
function GameRunner:createLevel(count)
-- determine level
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
-- prepare dungeon
TileLock=false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
-- customize rooms and connections
self.rooms = dungeon:createRandomRooms(count)
dungeon:connectRooms(self.rooms)
-- paint dungeon correctly
dungeon:convertEdgesToWalls()
-- ready for monsters
self.monsters = Monsters()
self:placePlayerInRoom1()
-- customize contents
self:placeWayDown()
self:placeSpikes(5)
self:placeLever()
self:setupMonsters(6)
self.keys = self:createThings(Key,5)
self:createThings(Chest,5)
self:createLoots(10)
self:createDecor(30)
self:createButtons()
-- prepare crawl
Announcer:clearCache()
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
self:startTimers()
self.playerCanMove = true
TileLock = true
end
function GameRunner:createTestLevel(count)
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
-- prepare
TileLock=false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
-- customize for level
self.rooms = dungeon:createRandomRooms(count)
dungeon:connectRooms(self.rooms)
dungeon:convertEdgesToWalls()
self.monsters = Monsters()
--self:placePlayerInRoom1()
-- customize contents
--self:placeWayDown()
--self:placeSpikes(5)
--self:placeLever()
--self:setupMonsters(6)
--self.keys = self:createThings(Key,5)
--self:createThings(Chest,5)
--self:createLoots(10)
--self:createDecor(30)
--self:createButtons()
-- prepare crawl
--Announcer:clearCache()
--self.cofloater:startCrawl()
--Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
--self:startTimers()
-- finalize level
self.playerCanMove = true
TileLock = true
end
These methods are all larger than my screen, and I can’t split horizontally to compare them, at least not in Codea.
I could move them over to the 27” iMac and see them side by side. Might be worth doing.
Let’s try some extracts. I think I want to give the extracted methods some kind of common names, so that they’ll be readily found and moved.
My plan is to pick one of the big create methods, extract one of the common bits, call it from there, check the other two to make sure they’re consistent in their expectations, and call the same method from them.
I’ll start with prepare. I think I’ll start all the new methods with dc
for dungeon creation. We’ll fix that before we’re totally done, though perhaps not today.
function GameRunner:dcPrepare()
TileLock = false
self:createNewDungeon()
local dungeon = self.dungeon
self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
dungeon:clearLevel()
end
With that plugged into all three, their further references to dungeon
won’t work, so we’ll change those all to self.dungeon
.
That passes tests and runs. Commit: extract and use dcPrepare
in level creation.
Another extract and use:
function GameRunner:dcFinalize()
self.playerCanMove = true
TileLock = true
end
I just noticed that the second room announcement in the learning level, the one about moving to the health item, does not come out on replay. But the initial message does come out. That has me wondering how that could even happen.
I don’t think I’ve just broken that with this refactoring. I think I missed it earlier. So I’ll commit: extracted dcFinalize from level creation.
Gumption Trap
This is the sort of thing that takes the wind out of one’s sails, but at least we’re at a good point to be distracted. I think I’ll instrument the caching in Announcer.
Ah. Subtle, and silly, defect. We cache Announcers when they’re created. That means that we need to be sure not to clear the cache after they’re all created. Or, we could cache them when they are triggered, not when created. That makes more sense, I think. Let’s do that.
Hm easier said than done. I think I’ve created an infinite recursion somehow. Revert. Smaller, more careful steps.
Instrument.
Here’s Announcer:
local AnnouncerCache = {}
Announcer = class()
function Announcer:playCache()
for i,ann in ipairs(AnnouncerCache) do
ann:repeatMessages()
Bus:publish("addTextToCrawl", self, {text="---"})
end
end
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
function Announcer:getTile(defaultTile)
return defaultTile
end
function Announcer:messages()
if self.sayMessages then
self.sayMessages = false
return self.messageTable
else
return {}
end
end
function Announcer:repeatMessages()
self.sayMessages = true
self:trigger()
end
function Announcer:trigger()
for i,m in ipairs(self:messages()) do
Bus:publish("addTextToCrawl", self, {text=m})
end
end
We send trigger
to an announcer to tell it to play. We presently put them into the cache when they are created. This is a problem because at the end of initializing a level we say this:
Announcer:clearCache()
self.cofloater:startCrawl()
Announcer(self:crawlMessages(self.dungeonLevel)):trigger()
We’ll have created, and cached, a number of Announcers already. I was going to try to change Announcer not to cache until played, but that got me into a loop. Let’s move the clear up to the dcPrepare
. That’s a temporal coupling, but an important one.
Ah. That’s “correct” but still wrong. Why? Because now, if I press replay, it will replay all the Announcers, even the ones who have never played. It amounts to a preview rather than a replay.
OK. We still need to cache them, not when created, but when triggered. Or maybe there’s another approach. We know that they turn off their one-time flag when triggered. What if replay only plays the ones who are turned off?
That might work. Let’s try.
function Announcer:playCache()
for i,ann in ipairs(AnnouncerCache) do
if not ann.sayMessages then
ann:repeatMessages()
Bus:publish("addTextToCrawl", self, {text="---"})
end
end
end
OK. If they have sayMessage == true, they’ve never spoken and should not be replayed. That works. Now I want to put out that “—” only in between the messages, not after the last one. Readily done:
function Announcer:playCache()
for i,ann in ipairs(AnnouncerCache) do
if not ann.sayMessages then
ann:repeatMessages()
if i < #AnnouncerCache then
Bus:publish("addTextToCrawl", self, {text="---"})
end
end
end
end
Works as intended. Commit: fix issue with announcers not replaying properly.
I’ve run a bit over time, so let’s sum up what has happened today.
Summary
We have a new feature, which is that the initial dungeon message is now cached for replay. Replay is still bound into the ?? key, and will need to be changed but that’s the initial choice we made to get the feature to be able to be shown.
We had to retain the runCrawl
method to keep some important tests running. We should consider whether those can be simplified.
We found a cute defect in our CodeaUnit code, causing errors that come in multiples of ten not to force the Big Red Display. Fixed that.
We noticed crawl text behind monsters and fixed that by drawing things in the right order.
We made sure to clear the Announcer cache in all three dungeon creation methods, which inspired us to begin to refactor out the duplication in those. In so doing we put the Announcer cache clearing in the wrong place and after discovering the problem, moved it.
The bugs there were rather subtle, including messages not coming out that should have, and then messages coming out that shouldn’t have. Announcer tests need to be enhanced.
Took a couple-three tries to get the Announcer right. I’m confident that it’s right, now.
We removed a redundant set of dashes from the replay output. Whee.
Lots of small things today, and very little trouble.
Not no trouble, however. There was some fumbling with Announcer. If we do decide to make it less subject to when the cache is cleared, we should be sure to add more tests.
And it’s seriously clear that we have no real “is this dungeon OK” code to verify dungeons being properly constructed. That might be worth thinking about, especially as we build our Making App.
A good morning, if made up of small stuff. See you next time!