Dungeon 107
Hide the WayDown. Make the spikes spikier. A new test, and a smarter DeferredTable improves some code.
I often have some idea of what I’m going to do in one of these articles, but usually I follow my nose to a large degree. Partly that’s because I am random, but partly it’s because I want to think, and write, about interesting aspects of development.
The game really needs better tiles, more realistic monsters, and other elements like that. But the learning from doing those wouldn’t be the kind of learning I’m after, software development learning. I’m not really trying to create a game that people want to play. I’m trying to find out what’s involved in programming such a game.
With that kind of motivation, I just swim where the krill seem to be most tasty.
On our list of things we ought to do, the WayDown is near the top. We really ought to put it somewhere other than right beside the princess when the game starts. We should at least put it far away from her, and ideally we’d figure out a way to surround it with fearsome monsters and maybe some kind of “boss”, and make it accessible only after solving the level’s puzzle, and so on. For now, we’ll settle for putting it a bit more out of the way.
Let’s try to define “out of the way”. The game places N non-overlapping rooms randomly, and then connects room N to N-1 all the way down to 1. Connection is done by carving hallways horizontally and vertically between the rooms we’re connecting, which means that the hallway between any two rooms can, in principle, cross over any other room. In practice, this seems to happen almost always, resulting in very interesting pathways through the dungeon.
Use Room N?
We always place the princess in the center of Room 1. One definition of “out of the way” might be to place the WayDown in Room N (N is presently 12, but could be anything.) Since rooms are placed randomly, it’s possible that the WayDown would be right next door to Room 1. It could be anywhere.
Random far-away tile?
Another possibility would be to search for room tiles that are “far away” from the center of Room 1. Hm. As I think about it, I rather like that. What if we do something like this: for some number of tries, maybe 20, we select a random room tile. If that position is the furthest from the center of room 1 so far, we save it. Rinse, repeat.
This would be easy to code, almost certainly fast enough, and would have the advantage of following no discernible pattern. The room found might still, however, be connected directly to Room 1.
Furthest path?
For extra credit, we could compute the room with the longest path from Room 1, defining longest as simple distance, or as the minimum number of other rooms you have to cross, or some combination like that. In my view that would be “extra credit”, because it seems like a lot of work that would result in something not much better than the other alternatives.
He decides!
I’m going to go with the random far-away tile algorithm. We have most of the mechanism built already, in other placement routines, so it should be easy to draft up this one. And, of course, it’ll be isolated into a method so that if we decide to do something else, we’ll just1 have to change that one method.
Let’s Go!
GameRunner’s createLevel
method calls placeWayDown
:
function GameRunner:placeWayDown()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx-2,rcy-2))
WayDown(tile,self)
end
We want to find a tile that is “far from” the center of room 1, which we presently happen to have in hand.
I’m in that mode again, of knowing what I want to program, and not knowing how I could TDD it. The code we’re about to write will simply iterate over a bunch of calls to randomRoomTile
, checking their distance from center, and holding on to the furthest one. How could we TDD that? How could it go wrong?
I’m sorry, I’m going to code it. Call this a spike if you wish.
function GameRunner:placeWayDown()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getDungeon():farawayOpenRoomTile(vec2(rcx-2,rcy-2))
WayDown(tile,self)
end
That can’t be wrong, doesn’t need a test.
function Dungeon:farawayOpenRoomTile(position)
local candidate
local candidateDist
local result = self:randomRoomTile(1)
local resultDist = position:dist(result:pos())
for i = 1,20 do
candidate = self:randomRoomTile(1)
candidateDist = position:dist(candidate:pos())
if candidateDist > resultDist then
result = candidate
resultDist = candidateDist
end
end
return result
end
This could be wrong, and could need a test. I’m going to run it and see what happens.
In the picture above, Room 1 is the one at the far left, and the princess has found the WayDown in the room at the far top right. I still don’t see a decent way to test this with all that randomness going on.
A little searching tells me that there’s a function Tile:distance(aTile)
. We could use that if we had a tile in hand instead of a position. It would be less efficient, but perhaps more clear. I’m going to refactor a bit without benefit of tests. Hold my chai.
function Dungeon:farawayOpenRoomTile(position)
local target = self:getTile(pos)
local candidate
local result = self:randomRoomTile(1)
for i = 1,20 do
candidate = self:randomRoomTile(1)
result = self:furthestFrom(target, result, candidate)
end
return result
end
Well, now we need furthestFrom
and by golly we can TDD that at least.
_:test("furthest from", function()
local tile, near, far, furthest
tile = Tile:room(0,0,Runner)
near = Tile:room(10,10,Runner)
far = Tile:room(100,100,Runner)
furthest = dungeon:furthestFrom(tile, near, far)
_:expect(furthest).is(far)
end)
This had better fail looking for furthestFrom
. Well, it doesn’t, because we can’t create a level until this works, because TestDungeon tab creates a whole level. Whee.
No problem, we know what we want:
function Dungeon:furthestFrom(target, r1, r2)
local d1 = r1:distance(target)
local d2 = r2:distance(target)
if d2 > d1 then return r2 else return r1 end
end
Typo here pos for position:
function Dungeon:farawayOpenRoomTile(position)
local target = self:getTile(position)
local candidate
local result = self:randomRoomTile(1)
for i = 1,20 do
candidate = self:randomRoomTile(1)
result = self:furthestFrom(target, result, candidate)
end
return result
end
The tests are green and the WayDown is far away. Again the room to the left is where we started and the WayDown is in the far right room.
Let’s return to where we call farawayOpenRoom:
function GameRunner:placeWayDown()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getDungeon():farawayOpenRoomTile(vec2(rcx-2,rcy-2))
WayDown(tile,self)
end
I don’t entirely love that, since it is working in x and y coordinates and vectors. Let’s give Room a new function centerTile
:
function Room:centerPos()
local x,y = self:center()
return vec2(x,y)
end
function Room:centerTile()
return self.runner:getTile(self:centerPos())
end
Then we can use that here:
function GameRunner:placeWayDown()
local r1 = self.rooms[1]
local target = r1:centerTile()
local tile = self:getDungeon():farawayOpenRoomTile(target)
WayDown(tile,self)
end
And change this method signature:
function Dungeon:farawayOpenRoomTile(target)
local candidate
local result = self:randomRoomTile(1)
for i = 1,20 do
candidate = self:randomRoomTile(1)
result = self:furthestFrom(target, result, candidate)
end
return result
end
This should be good to go. Tests are green.
The WayDown is in the room at top left, princess started at far right. I think this works a treat, and we actually managed to write a test for the tricky bit.
Commit: WayDown is placed far away from player.
Retro
Now, a quick retro, and my dear wife has decided to make a Saturday breakfast.
The idea for placing the WayDown far away was pretty simple. In terms of new code, it was probably no more than the choice of using Room 12, and of course Room 12 isn’t guaranteed to be furthest away.
I suppose we could have looked for the Room furthest from Room 1, but that would be just about as much effort and then we’d need to pick a random location in that room, which happens not to be a function that we have.
So far, the technique of probing randomly into the dungeon array until you find a tile you like is working out incredibly well. It’s certainly fast enough that dungeon generation takes no discernible time. When you enter a WayDown, the new room appears almost too quickly. I’ve been thinking that I need to create some special effect to make it more dramatic.
And I managed to create a credible test for the furthestFrom
function, so that actually increases confidence a bit. That pleases me. Despite the fact, and it is a fact, that I’ve not been able to think of tests quickly enough, that seem easy enough to write, I am missing the kind of iron-clad confidence one gets from a truly comprehensive suite of programmer TDD tests.
I’m not saying that my decisions not to test something were wrong, but I am reporting that each time I write something without automated tests, my confidence meter drops a bit, and it never really quite comes back up even after manually testing.
Defects in my daily “shipped” version have been few, but there have been some, and that bothers me as well.
So, today, so far, I’m pleased to have a new feature, and a slightly better test suite. Let’s see what I do next.
What Now?
OK, yummy breakfast complete. Covid shot #1 scheduled. What shall we do now?
One small thing. I want to change the Spikes to only do one point of damage when they’re down. But I’d like them to repeat damage every time they go up on you. The first part should be easy,
function Spikes:init(tile, tweenDelay)
self.delay = tweenDelay or tween.delay
self.tile = tile
self.tile:addDeferredContents(self)
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:up()
self:toggleUpDown()
end
We just need to change the hi
value in down
:
function Spikes:init(tile, tweenDelay)
self.delay = tweenDelay or tween.delay
self.tile = tile
self.tile:addDeferredContents(self)
self.damageTable = { down={lo=1,hi=1}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:up()
self:toggleUpDown()
end
Now usually we see an interaction from the player trying to enter, which triggers:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:appendText("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
We go up and down thusly:
function Spikes:down()
self.state = "down"
end
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
self.delay(2, self.toggleUpDown, self)
end
function Spikes:up()
self.state = "up"
end
What if, in up
, we were to check whether the princess is standing on us, and give her another jolt?
function Spikes:up()
self.state = "up"
local player = self.tile:getPlayerIfPresent()
if player then self:actionWith(player)
end
We’ll leave it to the tile to decide to give us the player or nil.
Turns out that we never call “up” except in init. We just change state in the toggle method. So I move the stuff:
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
if self.state == "up" then
local player = self.tile:getPlayerIfPresent()
if player then self:actionWith(player) end
end
self.delay(2, self.toggleUpDown, self)
end
And …
function Tile:getPlayerIfPresent()
for k,v in pairs(self.contents) do
if v == self.runner.player then
return v
end
end
return nil
end
This works:
I notice that we continue to impale and damage the dead princess. This is at best rude and arguably grotesque.
function Tile:getPlayerIfPresent()
for k,v in pairs(self.contents) do
if v == self.runner.player and v:isAlive() then
return v
end
end
return nil
end
That’s better. Commit: spikes repeatedly impale princes if she’s not smart enough to move.
This code seems like feature envy. I’d like to be able to ask the contents if it has the player. It turns out that contents is a DeferredTable, so we can in fact give it new capability:
function Tile:getPlayerIfPresent()
local player = self.runner.player
if self:contains(player) then return player else return nil end
end
function Tile:contains(thing)
return self.contents:contains(thing)
end
function DeferredTable:contains(thing)
for k,v in self:pairs() do
if v == thing then return true end
end
return false
end
I expect this to continue to work. And it does. This is better. Commit: refactor Tile:getPlayerIfPresent to use new DeferredTable:contains.
So that’s nice One more thing. With Spikes recurring, they might be a bit too powerful. Let’s dial them down just a bit:
self.damageTable = { down={lo=1,hi=1}, up={lo=4,hi=7}}
That’s a bit more gentle. Commit: Spikes less vicious.
OK, any other low-hanging fruit? I can’t think of anything I want to do, let’s sum up.
Summing Up
We’ve done some good stuff today. We positioned the WayDown far away from the princess, using a fairly decent definition of far away, namely in a room that’s further from the princess than 19 other tries were. That seems quite fast enough.
We improved the tests a bit along the way.
Then we made the spikes less vicious per attack, but we made them attack repeatedly if you’re dull enough just to stand there. We (OK, I) did this without benefit of net. I don’t see much worth testing in that code even now. One good thing was that I implemented a contains
method on DeferredTable, and I actually plan to use a similar approach elsewhere. When we have a method like that, or other methods like map or reduce, we can generally improve our code that uses those collections, by deferring more decisions downward.
We’ll see whether that pays off as we go forward. I feel sure that it will.
Our game is a bit better, our code has one more test, and the structure of our new work is better than work that has gone on before. We’ll look for other places to use that same pattern, as time goes on.
-
There’s that word again … ↩