Dungeon 264
Some tiny improvements … or are they?
We’ve been trying to move dungeon building out of GameRunner and Dungeon, and into the new DungeonBuilder class. This morning, I plan to take a look at where we are, and see what might be made better.
I should mention again that on a real product effort, we would probably not have the opportunity for an extended period of code improvement as seen in these articles. We might well have in mind “move builder code from GameRunner and Dungeon to DungeonBuilder”, but we’d only grab a few minutes or an hour every now and then, moving slowly toward the improved structure. We’d try to make every step a noticeable improvement, but we’d expect to take very many small steps. That would be fine with us, because we’re all about taking Many More Much Smaller Steps anyway, following GeePaw Hill’s fine motto. And eve the work in these articles has consisted of many commits, probably 45 or 50 commits, each one leaving the program working fine and the code a bit better.
Well, almost each one. I think I intentionally did a broken commit for a few minutes.
At the top, our dungeon creation looks like this:
function GameRunner:createLearningLevel()
self:defineDungeonBuilder(99)
self.builder:buildLearningLevel()
self:prepareToRun()
end
function GameRunner:createLevel(roomCount)
self:defineDungeonBuilder()
self.builder:buildLevel(roomCount)
self:prepareToRun()
end
function GameRunner:createTestLevel(roomCount)
self:defineDungeonBuilder()
self.builder:buildTestLevel(roomCount)
-- self:prepareToRun() -- probably not needed
end
There’s duplication here that we’d like to get rid of. And there’s a createHexLevel
method that isn’t converted. I’m still trying to convince myself to defer that whole hex idea. We might learn a lot but I wouldn’t enjoy it. While our learning is important to me, my general enjoyment is more important. It’s not like I’m getting paid to do this stuff.
I’m wondering now how often GameRunner actually references the builder. I think it was in place most as a temporary measure. Let’s find out.
I find this:
function GameRunner:setupMonsters()
self.builder:setupMonsters()
end
That’s a tag end left over from the transition. We moved things incrementally by letting DungeonBuilder and GameRunner pass messages back and forth as we moved things over. That method is no longer used. Deleted. Test, commit: remove unused GameRunner:setupMonsters.
Now the only references to a builder are there in those methods above (and a nil initialization in init
). Let’s not store the builder any more:
function GameRunner:defineDungeonBuilder(providedLevel)
if providedLevel then
self.dungeonLevel = providedLevel
else
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
end
self.dungeon = Dungeon(self, self.tileCountX, self.tileCountY)
local numberOfMonsters = 6
self.builder = DungeonBuilder(self,self.dungeon, self.dungeonLevel, self.playerRoom, numberOfMonsters)
end
This method initializes two member variables, dungeon
and builder
, and I rather suspect the “right” thing will turn out to be having the builder return a dungeon all fully crafted. One thing at a time, though. First, change to return the builder.
function GameRunner:defineDungeonBuilder(providedLevel)
if providedLevel then
self.dungeonLevel = providedLevel
else
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
end
self.dungeon = Dungeon(self, self.tileCountX, self.tileCountY)
local numberOfMonsters = 6
return DungeonBuilder(self,self.dungeon, self.dungeonLevel, self.playerRoom, numberOfMonsters)
end
Now fix the callers to expect that.
function GameRunner:createLearningLevel()
local builder = self:defineDungeonBuilder(99)
builder:buildLearningLevel()
self:prepareToRun()
end
function GameRunner:createLevel(roomCount)
local builder = self:defineDungeonBuilder()
builder:buildLevel(roomCount)
self:prepareToRun()
end
I expect this to work. I’ll test of course, to find out why I’m mistaken. Oops, there were three:
function GameRunner:createTestLevel(roomCount)
local builder = self:defineDungeonBuilder()
builder:buildTestLevel(roomCount)
-- self:prepareToRun() -- probably not needed
end
And a test fails, I think my one and only test directed at DungeonBuilder:
1: creation no builder -- Actual: nil, Expected: nil
_:test("creation", function()
local runner = GameRunner()
_:expect(runner.builder).is(nil)
runner:defineDungeonBuilder()
local builder = runner.builder
_:expect(builder, "no builder").isnt(nil)
_:expect(builder.RUNNER, "no runner").is(runner)
_:expect(builder.dungeon, "no dungeon").isnt(nil)
end)
That becomes:
_:test("creation", function()
local runner = GameRunner()
_:expect(runner.builder).is(nil)
local builder = runner:defineDungeonBuilder()
_:expect(builder, "no builder").isnt(nil)
_:expect(builder.RUNNER, "no runner").is(runner)
_:expect(builder.dungeon, "no dungeon").isnt(nil)
end)
Nicer anyway, didn’t have to rip the guts out of the GameRunner this way. Tests green. Game works. Commit: GameRunner no longer has DungeonBuilder member variable.
Now it seems reasonable that DungeonBuilder would return a dungeon rather than be given one.
Let’s code that by intention:
function GameRunner:createLearningLevel()
local builder = self:defineDungeonBuilder(99)
self.dungeon = builder:buildLearningLevel()
self:prepareToRun()
end
function GameRunner:createLevel(roomCount)
local builder = self:defineDungeonBuilder()
self.dungeon = builder:buildLevel(roomCount)
self:prepareToRun()
end
function GameRunner:createTestLevel(roomCount)
local builder = self:defineDungeonBuilder()
self.dungeon = builder:buildTestLevel(roomCount)
-- self:prepareToRun() -- probably not needed
end
Were we to run this now I think things would explode. Let’s find out. Yes. Hundreds of tests fail. Fix the problem:
function DungeonBuilder:buildLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:placePlayer()
self:customizeContents()
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildTestLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildLearningLevel()
self:dcPrepare()
self:defineLearningLayout()
self:placePlayer()
self:customizeLearningContents()
self:dcFinalize()
return self.dungeon
end
And don’t save the dungeon member variable here:
function GameRunner:defineDungeonBuilder(providedLevel)
if providedLevel then
self.dungeonLevel = providedLevel
else
self.dungeonLevel = self.dungeonLevel + 1
if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
end
local dungeon = Dungeon(self, self.tileCountX, self.tileCountY)
local numberOfMonsters = 6
return DungeonBuilder(self,dungeon, self.dungeonLevel, self.playerRoom, numberOfMonsters)
end
This ought to work. Well. That’s how much I iknow:
GameRunner:201: attempt to index a nil value
stack traceback:
GameRunner:201: in method 'getTile'
Tile:290: in method 'getSurroundingInfo'
Tile:156: in method 'convertEdgeToWall'
Dungeon:194: in method 'convertEdgesToWalls'
DungeonBuilder:154: in method 'defineDungeonLayout'
DungeonBuilder:61: in method 'buildTestLevel'
GameRunner:64: in method 'createTestLevel'
Tests:23: in field '_before'
CodeaUnit:44: in method 'test'
Tests:36: in local 'allTests'
CodeaUnit:16: in method 'describe'
Tests:13: in function 'testDungeon2'
[string "testDungeon2()"]:1: in main chunk
CodeaUnit:139: in method 'execute'
Main:13: in function 'setup'
I’ll take one look, then if it’s not obvious, I’ll revert. The problem is here:
_:before(function()
_TileLock = TileLock
_tweenDelay = tween.delay
tween.delay = fakeTweenDelay
_bus = Bus
Bus = EventBus()
runner = GameRunner(20,20)
runner:defineDungeonBuilder()
runner:createTestLevel(0)
_dc = DungeonContents
DungeonContents = DungeonContentsCollection()
TileLock = false
end)
There’s a somewhat general lesson here. My tests, like this one, are tricky to set up, because I’m building up a testable nest of objects, while trying not to create an entire game to test. So the tests do odd things. This is a sign of something we should think about. But for now …
OK, I don’t see the problem. I think it is a callback to runner during creation, that requires runner to know the dungeon. Revert. Test. We’re good.
Our mission is to have DungeonBuilder return the dungeon as built. That part should work, so let’s do this:
function DungeonBuilder:buildLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:placePlayer()
self:customizeContents()
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildTestLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildLearningLevel()
self:dcPrepare()
self:defineLearningLayout()
self:placePlayer()
self:customizeLearningContents()
self:dcFinalize()
return self.dungeon
end
That should be harmless, no one is looking. Test. Commit: dungeon building returns dungeon from DungeonBuilder methods.
Now let’s see who is talking to the runner at the behest of DungeonBuilder, and why.
...
self.dungeon:createRoomsFromXYWHA(t, self.RUNNER)
...
MonsterPlayer(self.RUNNER)
...
function DungeonBuilder:placePlayer()
local r1 = self:getRooms()[self.playerRoomNumber]
local tile = r1:centerTile()
self.RUNNER:placePlayer(tile)
end
...
function DungeonBuilder:placeWayDown()
local r1 = self:getRooms()[1]
local target = r1:centerTile()
self.wayDownTile = self.dungeon:farawayOpenRoomTile(r1, target)
local wayDown = WayDown(self.wayDownTile)
self.RUNNER:setWayDown(wayDown)
end
...
function DungeonBuilder:setupMonsters()
local monsters = Monsters()
monsters:createRandomMonsters(self.RUNNER, self.numberOfMonsters, self.levelNumber, self.playerRoomNumber)
monsters:createHangoutMonsters(self.RUNNER, 2, self.wayDownTile)
self.dungeon:setMonsters(monsters)
end
Now, we have the legitimate runner, so what we care about is whether any of those methods, when called, ask the runner to do something that will cause it to try to access its dungeon member variable, which is not yet set.
Why am I doing this? Two reasons, at least. First, it seems like the DungeonBuilder should build and return a Dungeon instance. Second, setting up the GameRunner member variable where it is now seems like a hidden side effect, and that’s worth improving if we can. So we’ll do a little searching.
function Dungeon:createRoomsFromXYWHA(roomTable, runner)
local makeRoom = function(result,roomDef)
return result + Room:fromXYWHA(roomDef, runner)
end
self.rooms = ar.reduce(roomTable, Array{}, makeRoom)
end
function Room:fromXYWHA(xywhaTable, runner)
local x,y,w,h,a = table.unpack(xywhaTable)
return Room(x,y,w,h, runner, true, a)
end
function Room:init(x,y,w,h, runner, paint, announcement)
if paint == nil then paint = true end
self.x1 = x
self.y1 = y
self.x2 = x + w - 1
self.y2 = y + h - 1
self.runner = runner
if paint then
self:paint()
end
if announcement then
self:announce(runner:getDungeon(), announcement)
end
end
function Room:paint()
for x = self.x1,self.x2 do
for y = self.y1,self.y2 do
local existing = self.runner:getTile(vec2(x,y))
existing:convertToRoom()
end
end
end
Meh. Here we call back to the runner to get the tile. That will, of course, ask the Dungeon:
function GameRunner:getTile(aPosition)
return self:getDungeon():getTile(aPosition)
end
You’d think that the Room would know a dungeon to be in, not just a runner. I think it may actually need the runner for some purpose. Let’s find out.
A little research, more that I’d like to do, tells me that Room does not need to know the runner, but that Room creates Tile instances, and they need to know the runner because they need to be able to return the player if they have her:
function Tile:getPlayerIfPresent()
local player = self.runner.player
if self:contains(player) then return player else return nil end
end
I’m fairly certain that all the other references to runner in Tile could just as well refer to the dungeon instead.
One moderately hacky hack would be to change the check for contains to check for an object that is_a
Player
. Then we could rewire Tile to accept a Dungeon instead of a Runner on creation and then change all its methods.
This seems like too much, at least for now. We can make a card to convert Tile to receive Dungeon instead of GameRunner.
But wait, let’s see who calls that method.
function Spikes:toggleUpDown()
local tile = self:getTile()
if tile == nil then return end -- we're being called back from a previous dungeon.
self.state = ({up="down", down="up"})[self.state]
if self.stayDown then self.state = "down" end
if self.state == "up" then
local player = tile:getPlayerIfPresent()
if player then self:actionWith(player) end
end
self.delay(2, self.toggleUpDown, self)
end
There’s only the one. Let’s try something:
function Spikes:toggleUpDown()
local tile = self:getTile()
if tile == nil then return end -- we're being called back from a previous dungeon.
self.state = ({up="down", down="up"})[self.state]
if self.stayDown then self.state = "down" end
if self.state == "up" then
local player = tile:getInstanceIfPresent(Player)
if player then self:actionWith(player) end
end
self.delay(2, self.toggleUpDown, self)
end
function Tile:instanceOrNil(klass)
for i,t in ipairs(self:getContents()) do
if t:is_a(klass) then return t end
end
return nil
end
I have high hopes for this. Test. Tests run. Commit: Remove Tile: getPlayerIfPresent, replace with getInstanceOrNil(Player). Tile:contains currently unused.
Now, if I’m not mistaken–and often I am mistaken–we’ve made Tile able to be created with a Dungeon member variable rather than a GameRunner. That’s a rather substantial design difference, and our time for refactoring is up for today. Let’s make a couple of cards:
Issue 31: Runner is called during dungeon build, so we can’t just return the dungeon when done. Change Room and Tile to have Dungeon, not Runner.
Issue 32: Tile references its runner member variable mostly/only to get a dungeon. Change to have only Dungeon. See Issue 31.
That’s probably enough to remind me what to do next. I might wrote more, or put notes in the code, if I thought it would be a while before anyone got to this.
Just wondering: in all the hours you and your team spend programming every week, how many hours might one or two of you be able to put into improving the code with no discernible delay to anything important? I bet it would be possible …
Let’s sum up.
Summary
We’ve made a few small improvements to the dungeon building, moving in the direction of having no building code in GameRunner, and very little in Dungeon.
We have some duplication that may need addressing:
function GameRunner:createLearningLevel()
local builder = self:defineDungeonBuilder(99)
builder:buildLearningLevel()
self:prepareToRun()
end
function GameRunner:createLevel(roomCount)
local builder = self:defineDungeonBuilder()
builder:buildLevel(roomCount)
self:prepareToRun()
end
function GameRunner:createTestLevel(roomCount)
local builder = self:defineDungeonBuilder()
builder:buildTestLevel(roomCount)
-- self:prepareToRun() -- probably not needed
end
And here:
function DungeonBuilder:buildLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:placePlayer()
self:customizeContents()
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildTestLevel(roomCount)
self:dcPrepare()
self:defineDungeonLayout(roomCount)
self:dcFinalize()
return self.dungeon
end
function DungeonBuilder:buildLearningLevel()
self:dcPrepare()
self:defineLearningLayout()
self:placePlayer()
self:customizeLearningContents()
self:dcFinalize()
return self.dungeon
end
I’m tempted to do something clever here, like some kind of Execute Around Method, but perhaps there is a less clever way to remove this duplication. I imagine that in the fullness of time we’d like to pass in a data structure or object that defined the dungeon, ideally a text string or file. But that is not for today.
For today, a few improvements to the code. Good enough.