Dungeon 274
Let’s see whether I am more clever than yesterday, and whether my ‘make the change easy’ mods were useful.
Yesterday I was not at my best–not to say that my best is any great shakes–so I bailed after making some changes intended to make the next steps easier. This was driven by Kent Beck’s advice:
To make a difficult change, make the change easy, then make the easy change.
I was thinking about the random room tile code, which now starts like this:
function DungeonBuilder:randomRoomTileAvoidingRoomNumber(roomNumberToAvoid)
local avoidedRoom = nil
if roomNumberToAvoid < #self:getRooms() then
avoidedRoom = self:getRooms()[roomNumberToAvoid]
end
return self.dungeon:randomRoomTileAvoidingRoom(avoidedRoom)
end
I found methods with the same name and different signatures and operation, which made it difficult to move that randomRoomTileAvoidingRoom
code over into DungeonBuilder, where I believe it belongs.
First, let’s look for references. I have a concern.
function GameRunner:randomRoomTileAvoidingRoomNumber(roomNumberToAvoid)
local room = nil
if roomNumberToAvoid < #self:getRooms() then
room = self:getRooms()[roomNumberToAvoid]
end
return self:getDungeon():randomRoomTileAvoidingRoom(room)
end
function Dungeon:farawayOpenRoomTile(room, target)
local candidate
local result = self:randomRoomTileAvoidingRoom(room)
for i = 1,20 do
candidate = self:randomRoomTileAvoidingRoom(room)
result = self:furthestFrom(target, result, candidate)
end
return result
end
function Dungeon:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
function Monster:getRandomMonsters(runner, number, level, roomNumberToAvoid)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTileAvoidingRoomNumber(roomNumberToAvoid)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
function Decor:createRequiredItemsInEmptyTiles(items, runner)
return ar.map(items, function(item)
local tile = runner:randomRoomTileAvoidingRoomNumber(666)
return Decor(tile,item)
end)
end
Wow. That’s rather nasty. Decor and Monster are relying on GameRunner for this method. These methods are happening at build time (I’m pretty sure that we don’t rez monsters, other than PathMonster, during game play.)
I think we can only move that top level method down by passing a builder to the Monster and Decor
But these objects … this can’t be the only reference they have that is relying on runner. (I don’t mind if they know the runner during play, but during build, I’d like to unwind them.)
There is an option. GameRunner just forwards to Dungeon. We can defer from there to builder and just allow the weird connection for now. Let’s try that.
Deferring the Method
We start with this:
function Dungeon:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
We move the method to DungeonBuilder:
function DungeonBuilder:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
We think we’d like to defer to it … oh my. The Dungeon doesn’t know its builder. Only GameRunner has a builder.
Ah. I think I see what is going wrong. These methods, even the ones I’ve already moved, are not really part of dungeon building, they are part of something we might call dungeon searching. It’s quite likely the case that they should be moved out of Dungeon, but moving them into DungeonBuilder isn’t the right thing.
Reflection
This is a case of the programmer wanting to do something, and the code wanting something else. It wouldn’t be all that hard for the runner method to spawn a DungeonBuilder and call it. But the fact that all these (few) methods want to talk with the runner means that we’d be forcing a connection that doesn’t exist.
This is hard to explain. I could do this. I know pretty nearly exactly how: spawn a builder and defer to it. But it doesn’t “feel” right. It’s not that it’s hard: it will be pretty easy. So I’m not resisting doing something that’s difficult: I’m resisting doing something that’s easy enough but feels wrong.
What should we do? I can see these options:
- Leave things as they are, and move to find other things that may need to be in DungeonBuilder, moving it more toward completion.
- Move the dungeon searching methods back to Dungeon, so that the newly recognized problem will be localized, and then see about completing DungeonBuilder.
- Create some new object, maybe DungeonAnalyzer, and start putting these methods in it, then move to advance both DungeonBuilder and DungeonAnalyzer.
If we were to schoose the first option, we leave the code less cohesive than it could be, with these analysis methods separated between builder and dungeon. If we were to choose the last option, we would be faced with methods from two sources, dungeon and builder, that need to be moved to the new analyzer. Those methods bounce back and forth between dungeon and builder and the changes would likely get tricky.
I think we should do this: First, move the dungeon searching/analysis methods back to Dungeon, and perhaps mark them or rename them for visibility, and then look at whether moving them again to an analyzer is more profitable, or whether continuing with builder is the thing to do.
I have to admit that I’m very tempted by the DungeonAnalyzer notion. It seems to me that it can be an ephemeral object. It doesn’t need to hang around. We can create one when we need it, call it one time, and throw it away. Or, of course we could cache one, but the cost of building and tossing is low and the style is better (according to my lights, YLMV).
Let’s try it. I’ve just reverted out what I did above, starting the move from Dungeon to Builder. We’re looking at a clean slate. Now we just have to clear our mind and see what needs doing.
One more thing pops into my mind as I think: what kind of access to the Dungeon will this new DungeonAnalyzer need? It clearly will need the rooms. Can it otherwise live with a TileFinder?
I think it all comes down to this method:
function Dungeon:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
local x,y, pos, tile
repeat
pos = self:randomPositionAvoiding(x1,y1, x2,y2, self.tileCountX, self.tileCountY)
tile = self:getTile(pos)
until tile:isOpenRoom()
return tile
end
That only relies on getTile
(of some format), so quite likely we can limit our access to something very narrow, like what TileFinder provides.
Let’s get started.
DungeonAnalyzer
Are you surprised that we’re doing this when just a few paragraphs above I said that I thought we should do something a bit different? That’s because in the paragraphs following it, I changed my mind, having not stopped thinking after writing that paragraph.
I don’t sanitize my articles, making them look like some god of programming wrote them, never making a mistake, a misstep, or changing their mind. I make them look like what programming really is to me, a never-ending series of looking, trying, deciding, and thinking, all in a tight bundle of activity.
None of my decisions are so great that I am not ready to change them in the light of evidence1.
So at this moment right here, I propose to create a new object, DungeonAnalyzer, and to move some of these tile finding methods over to it. I am going to rely on existing tests to do this, at least until there’s evidence that I need direct tests. I will, however, start with a test frame built into the class’s tab, because that will make it seem less of a hassle to add a test when I start feeling the need.
-- Dungeon Analyzer
-- 20220402
function testDungeonAnalyzer()
CodeaUnit.detailed = false
_:describe("DungeonAnalyzer", function()
_:before(function()
end)
_:after(function()
end)
_:test("creation", function()
local DA = DungeonAnalyzer()
end)
end)
end
DungeonAnalyzer = class()
function DungeonAnalyzer:init()
end
My plan is to start at the bottom this time, and move these methods:
function Dungeon:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
local x,y, pos, tile
repeat
pos = self:randomPositionAvoiding(x1,y1, x2,y2, self.tileCountX, self.tileCountY)
tile = self:getTile(pos)
until tile:isOpenRoom()
return tile
end
function Dungeon:randomPositionAvoiding(x1,y1, x2,y2, maxX, maxY)
local x,y
x = randomValueAvoiding(x1,x2,maxX)
y = randomValueAvoiding(y1,y2,maxY)
return vec2(x,y)
end
It’ll go like this:
function DungeonAnalyzer:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
local x,y, pos, tile
repeat
pos = self:randomPositionAvoiding(x1,y1, x2,y2, self.tileCountX, self.tileCountY)
tile = self:getTile(pos)
until tile:isOpenRoom()
return tile
end
function DungeonAnalyzer:randomPositionAvoiding(x1,y1, x2,y2, maxX, maxY)
local x,y
x = randomValueAvoiding(x1,x2,maxX)
y = randomValueAvoiding(y1,y2,maxY)
return vec2(x,y)
end
Now I’ll remove the second method from Dungeon, and forward the first:
function Dungeon:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
return DungeonAnalyzer(self):randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
end
I know some things will need changing. Let’s do the ones I can think of:
function DungeonAnalyzer:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
local x,y, pos, tile
repeat
pos = self:randomPositionAvoiding(x1,y1, x2,y2, self.dungeon.tileCountX, self.dungeon.tileCountY)
tile = self.dungeon:getTile(pos)
until tile:isOpenRoom()
return tile
end
This is a bit invasive, but it ought to work, or else provide me with some surprising information. Test. Tests run. Commit: DungeonAnalyzer has randomRoomTileAvoidingCoordinates.
Now let’s think about whether we’d like to pass in something a bit less terrifying than the whole dungeon. Right now we just need those two values and a TileFinder. Let’s improve Dungeon:
function Dungeon:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
return self:dungeonAnalyzer():randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
end
Now we “just” need to make one …
function Dungeon:dungeonAnalyzer()
return DungeonAnalyzer(self:asTileFinder(), self.tileCountX, self.tileCountY)
end
And to change DA:
function DungeonAnalyzer:init(finder, tileCountX, tileCountY)
self.finder = finder
self.tileCountX = tileCountX
self.tileCountY = tileCountY
end
function DungeonAnalyzer:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
local x,y, pos, tile
repeat
pos = self:randomPositionAvoiding(x1,y1, x2,y2, self.tileCountX, self.tileCountY)
tile = self.finder:getTile(pos.x,pos.y)
until tile:isOpenRoom()
return tile
end
I had to change the getTile
call to pass the x and y. I really wish I had chosen an approach and stuck with it.
Tests run. Commit: DungeonAnalyzer receives only tile finder and tile counts.
Now let’s see who calls our method and see about moving them over.
function Dungeon:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
This is the only one. Move it:
function DungeonAnalyzer:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
Defer to it:
function Dungeon:randomRoomTileAvoidingRoom(roomToAvoid)
return self:dungeonAnalyzer():randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
Test. Tests run. Remove:
function Dungeon:randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
return self:dungeonAnalyzer():randomRoomTileAvoidingCoordinates(x1,y1,x2,y2)
end
Test again. Tests run. Commit: randomRoomTileAvoidingRoom done by DungeonAnalyzer.
Do you feel that we could have done that in one step? Possibly. Two steps made it easier. I am easily distracted by cats wanting to go out, plus I prefer to juggle only one ball at a time.
Now callers of the method we just moved:
function GameRunner:randomRoomTileAvoidingRoomNumber(roomNumberToAvoid)
local room = nil
if roomNumberToAvoid < #self:getRooms() then
room = self:getRooms()[roomNumberToAvoid]
end
return self:getDungeon():randomRoomTileAvoidingRoom(room)
end
I think we’ll allow that one, which means our new deferring method may need to remain in Dungeon for a while. What else?
Here’s one:
function Dungeon:farawayOpenRoomTile(room, target)
local candidate
local result = self:randomRoomTileAvoidingRoom(room)
for i = 1,20 do
candidate = self:randomRoomTileAvoidingRoom(room)
result = self:furthestFrom(target, result, candidate)
end
return result
end
Let’s move it.
function DungeonAnalyzer:farawayOpenRoomTile(room, target)
local candidate
local result = self:randomRoomTileAvoidingRoom(room)
for i = 1,20 do
candidate = self:randomRoomTileAvoidingRoom(room)
result = self:furthestFrom(target, result, candidate)
end
return result
end
We’ll do the deferral now.
function Dungeon:farawayOpenRoomTile(room, target)
return self:dungeonAnalyzer():farawayOpenRoomTile(room, target)
end
I noticed the furthestFrom
. Had I not, the tests would have found it. Look for senders. There’s just the one, it’s an Explaining Method. Move it:
function DungeonAnalyzer:furthestFrom(targetTile, tile1, tile2)
local d1 = tile1:distanceFrom(targetTile)
local d2 =tile2:distanceFrom(targetTile)
if d2 > d1 then return tile2 else return tile1 end
end
I expect things to work. The game works but a test fails:
4: furthest from -- Dungeon:74: attempt to call a nil value (method 'furthestFrom')
_:test("furthest from", function()
local tile, near, far, furthest
tile = Tile:room(0,0,runner:tileFinder())
near = Tile:room(10,10,runner:tileFinder())
far = Tile:room(50,50,runner:tileFinder())
furthest = dungeon:furthestFrom(tile, near, far)
_:expect(furthest).is(far)
end)
We should really move this test into DungeonAnalyzer. Cut from where it is. Paste. This is why I made the test frame, it makes it easier to do a test. I need a runner for this and another change.
After a bit of fiddling, I just replicate the before
and after
and local variables from the other test:
local dungeon
local _bus
local _TileLock
local _dc
local runner
function testDungeonAnalyzer()
CodeaUnit.detailed = false
_:describe("DungeonAnalyzer", function()
_:before(function()
_bus = Bus
Bus = EventBus()
_dc = DungeonContents
DungeonContents = DungeonContentsCollection()
runner = GameRunner(25,15)
runner:createTestLevel(0)
dungeon = runner:getDungeon()
_TileLock = TileLock
TileLock = false
end)
_:after(function()
Bus = _bus
TileLock = _TileLock
DungeonContents = _dc
end)
_:test("creation", function()
local DA = DungeonAnalyzer()
end)
_:test("furthest from", function()
local finder = runner:tileFinder()
local analyzer = runner:getDungeon():dungeonAnalyzer()
local tile, near, far, furthest
tile = Tile:room(0,0,finder)
near = Tile:room(10,10,finder)
far = Tile:room(50,50,finder)
furthest = analyzer:furthestFrom(tile, near, far)
_:expect(furthest).is(far)
Bus = _bus
end)
end)
end
Tests all run, as does the game. I am surprised, however, to find a Mimic in the starting room. Is that allowed? Let’s look. I think this ought to be OK, but I’m not dead certain.
No, when we create random monsters we say to avoid Room 1. The room could have been expanded, that does happen. But I’m still not certain we’re OK.
I can commit the test changes anyway: move test to dungeonAnalyzer.
Now let’s see about those room checks.
I instrument this method:
function DungeonAnalyzer:randomRoomTileAvoidingRoom(roomToAvoid)
local x1,y1, x2,y2 = 0,0,0,0
if roomToAvoid then
x1,y1, x2,y2 = roomToAvoid:corners()
end
if roomToAvoid then
print("avoiding room ", roomToAvoid, x1,y1, x2,y2)
end
return self:randomRoomTileAvoidingCoordinates(x1,y1, x2,y2)
end
The prints are all OK. I’m confident that we haven’t broken anything.
It’s 1100 hours and I’ve been at this for a while. We’re at a solid point, so let’s sum up.
Summary
The only thing I like better than when a plan comes together is when a plan pushes back and we get an even better plan. We have now created two new central objects in the system, plus two Façade objects:
- TlleFinder
- This is a Façade covering Dungeon, with just a single method,
getTile
, which allows us to build objects with very limited access to the dungeon. It gets used in DungeonBuilder and Tile, as well as DungeonAnalyzer. It deserves more use, as time goes on. - BuilderView
- This is another Façade covering Dungeon, used to forward messages to Dungeon from DungeonBuilder. It changes over time as things move in and out, but always serves to limit what the Builder can access.
- DungeonBuilder
- This object is receiving more and more of the methods that are used in building the Dungeon. (Thus the name.) Over time, I hope to get all the dungeon building over in this object, but each move generally reduces the breadth of GameRunner or Dungeon, always a good thing.
- DungeonAnalyzer
- This new object is intended to contain methods used, typically during building, to find tiles with properties desired by objects being built. No monsters in room 1, Spikes only in hallways, and so on. So far it only has five methods, but it will get more. This helper class is ephemeral: you create one, ask it your question, and drop it.
What I like about today is that the overall design of the system has begun to change again, in addition to the builder idea. Now we have a new idea, analyzer. If this new object holds water, and I believe it will, it’ll be something that we “should” have thought of sooner, but now that we have thought of it, we can build it, improve it, and use it just as if we had been smarter back in the past.
We can’t make ourselves smart in the past, it’s too late. But we can make our lives look much as they would, had we been smarter in the past. It just takes a bit of time and refactoring.
A fun day. Better than yesterday, for sure.
What will happen next time? I can’t wait to find out.
-
I simply hate the current trope of accusing someone of “flip-flopping” when they change their mind on the basis of events. I try to hold on to ideas loosely. You may have heard the phrase “Strong opinions, weakly held”. You can google it for objections to the idea. Me, I just try to swim in the direction where the krill seem most tasty. If the currents change, I change where I swim. ↩