Let’s get some rooms up in this ditch.

I think room digging and hallway digging are working now, so it’s time to create random dungeons and see what we get. To accomplish that I think I’ll need most of these things:

  • Allocate a suitable size space for our rooms;
  • Create a random room that doesn’t overlap any others;
  • Create some number, probably around 30 random rooms;
  • Connect the rooms.

We’ll need far more than that to make a game, including things like zooming in on the current room, putting a player, treasure, monsters, and so on in the rooms, moving the player around. It goes on and on, until we’ve learned what we want to learn, and had enough fun to want to move on. But for now, building the dungeon should be sufficient to the day, maybe even to more than a day.

From the D1 version of this program, in the Rectangle class, we had this:

function Rectangle:intersects(aRectangle)
    x1lo, y1lo, x1hi, y1hi = self:corners()
    x2lo, y2lo, x2hi, y2hi = aRectangle:corners()
    if y1lo > y2hi or y2lo > y1hi then return false end
    if x1lo > x2hi or x2lo > x1hi then return false end
    return true
end

I reckon we’ll need that, and I see no reason right now not to make it part of room. What about testing? I could argue that this is code from a tested library. Or I could import the tests, because we did do a few. I’m going with the library excuse for now, let’s see if I regret it. And I will consider adapting the tests when I need something kind of easy to do.

So I add these:

function Room:intersects(aRoom)
    x1lo, y1lo, x1hi, y1hi = self:corners()
    x2lo, y2lo, x2hi, y2hi = aRoom:corners()
    if y1lo > y2hi or y2lo > y1hi then return false end
    if x1lo > x2hi or x2lo > x1hi then return false end
    return true
end

function Room:corners()
    return self.x1,self.y1,self.x2,self.y2
end

Now I need a random room. I think it will want to know something about the dimensions of the space, and the ranges of its width and height. I think I’ll write a test for this just to drive out some sense of how it’ll be used.

… No. I just don’t see a decent test to write. I may regret this. I’m just going to code createRandomRooms into GameRunner.

OK, I just kind of emitted this:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(60,60, 4,15)
            if self:hasRoomFor(r) then
                placed = true
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
end

My plan is that we’ll loop until we’ve placed count rooms or timed out count times. This is a bit odd but it’s what I came up with. TDD might have made this better, but I’m sinning this morning. After all, we’ve only thrown this program away once …

Now I need Room:random and GameRunner:hasRoomFor.

function Room:random(spaceX, spaceY, minSize, maxSize, runner)
    local w = math.random(minSize,maxSize)
    local h = math.random(minSize,maxSize)
    local x = math.random(0,spaceX-w)
    local y = math.random(0,spaceY-h)
    return Room(x,y,w,h,runner)
end

I noticed that I need the runner, so I’ll add that to the call beofre I forget:

            local r = Room:random(60,60, 4,15, self)

Now the hasRoomFor:

function GameRunner:hasRoomFor(aRoom)
    for i,room in ipairs(self.rooms) do
        if room:intersects(aRoom) then return false end
    end
    return true
end

I have to just call this now to see what it does.

GameRunner:78: attempt to index a nil value (field '?')
stack traceback:
	GameRunner:78: in method 'setTile'
	Room:25: in field 'init'
	... false
    end

    setmetatable(c, mt)
    return c
end:24: in function <... false
    end

    setmetatable(c, mt)
    return c
end:20>
	(...tail calls...)
	GameRunner:26: in method 'createRandomRooms'
	Main:28: in function 'setup'

Well that’s interesting. Here’s what displays:

rooms all over

So the rooms are all on top of each other. The intersection code is failing somehow. And am I counting down count? I am not. So:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        count = count -1
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(60,60, 4,15, self)
            if self:hasRoomFor(r) then
                placed = true
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
end

So what’s that message about? GameRunner 78 from Room init 25:

function GameRunner:setTile(aTile)
    self.tiles[aTile.x][aTile.y] = aTile
end

Seems like tile x or y may be nil? Let’s review:

function Room:random(spaceX, spaceY, minSize, maxSize, runner)
    local w = math.random(minSize,maxSize)
    local h = math.random(minSize,maxSize)
    local x = math.random(0,spaceX-w)
    local y = math.random(0,spaceY-h)
    return Room(x,y,w,h,runner)
end

function Room:init(x,y,w,h, runner)
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    self.runner = runner
    -- leave wall on all four sides
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            runner:setTile(self:correctTile(x,y))
        end
    end
end

We’re creating with parms 60,60,4,15. I’m not seeing at all why we’d be coming up with nil x or y here.

Time for a test, isn’t it? Past time, my better side might say.

        _:test("random rooms", function()
            local x1,y1,x2,y2
            local runner = GameRunner()
            for i = 1,100 do
                local r = Room:random(60,60, 4,15, runner)
                x1,y1,x2,y2 = r:corners()
                _:expect(x1>=0 and x1<60).is(true)
                _:expect(y1>=0 and y1<60).is(true)
                _:expect(x2>=0 and x2<60).is(true)
                _:expect(y2>=0 and y2<60).is(true)
            end
        end)

I just rolled up a bunch of them. And got this:

6: random rooms -- GameRunner:79: attempt to index a nil value (field '?')

It’s not happening all the time. I think I know what’s up. I think we’re indexing address 60 in the array. I think I need to be a bit more conservative in where the rooms can go:

function Room:random(spaceX, spaceY, minSize, maxSize, runner)
    local w = math.random(minSize,maxSize)
    local h = math.random(minSize,maxSize)
    local x = math.random(0,spaceX-w-1)
    local y = math.random(0,spaceY-h-1)
    return Room(x,y,w,h,runner)
end

If I’m right, the error will go away. It doesn’t. I’ll put an assert in that code in GameRunner.

function GameRunner:setTile(aTile)
    local x,y = aTile.x, aTile.y
    assert(x<60 and y< 60, "too big")
    self.tiles[aTile.x][aTile.y] = aTile
end

OK, this is asserting. Let’s actually print the room if it’s bad.

Oh my. Here’s one important issue:

function Room:init(x,y,w,h, runner)
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    self.runner = runner
    -- leave wall on all four sides
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            runner:setTile(self:correctTile(x,y))
        end
    end
end

We paint the room before we check it. That will no longer do. However, let’s leave that for now and do some checking on the rooms before we paint.

I freely state for your edification that I am a bit confused about what is happening. Oh. Wait. 60 is a legal index. 0 is not.

function Room:random(spaceX, spaceY, minSize, maxSize, runner)
    local w = math.random(minSize,maxSize)
    local h = math.random(minSize,maxSize)
    local x = math.random(1,spaceX-w)
    local y = math.random(1,spaceY-h)
    return Room(x,y,w,h,runner)
end

Now I expect somewhat better behavior. OK, I’m not getting the crash any more but my test sometimes fails. This will get messy with all those expects going by bur we’ll let that ride for now: Ah: the test had my zero-indexing mindset engaged as well. Revised as below it runs fine.

        _:test("random rooms", function()
            local x1,y1,x2,y2
            local runner = GameRunner()
            for i = 1,100 do
                local r = Room:random(60,60, 4,15, runner)
                x1,y1,x2,y2 = r:corners()
                _:expect(x1>0 and x1<=60).is(true)
                _:expect(y1>0 and y1<=60).is(true)
                _:expect(x2>0 and x2<=60).is(true)
                _:expect(y2>0 and y2<=60).is(true)
            end
        end)

I don’t see much reason to keep it however. Now can I test on the screen? No, not yet. The rooms still paint while we aren’t sure they are good. Let’s have them optionally paint:

function Room:init(x,y,w,h, runner, paint)
    if paint == nil then paint = true end
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    self.runner = runner
    if paint then
        self:paint()
    end
end

function Room:paint()
    -- leave wall on all four sides
    for x = self.x1,self.x2 do
        for y = self.y1,self.y2 do
            self.runner:setTile(self:correctTile(x,y))
        end
    end
end

Now we can do this in the random thingie:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        count = count -1
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(60,60, 4,15, self, false)
            if self:hasRoomFor(r) then
                placed = true
                r:paint()
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
end

This took me a time or two to get the flag right. It goes like this:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        count = count -1
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(60,60, 4,15, self)
            if self:hasRoomFor(r) then
                placed = true
                r:paint()
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
end

Now the random method just knows not to paint:

function Room:random(spaceX, spaceY, minSize, maxSize, runner)
    local w = math.random(minSize,maxSize)
    local h = math.random(minSize,maxSize)
    local x = math.random(1,spaceX-w)
    local y = math.random(1,spaceY-h)
    return Room(x,y,w,h,runner, false)
end

function Room:init(x,y,w,h, runner, paint)
    if paint == nil then paint = true end
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    self.runner = runner
    if paint then
        self:paint()
    end
end

The room layout looks good now:

rand1

rand2

Let’s do corridors now:

function GameRunner:createRandomRooms(count)
    self.rooms = {}
    while count > 0 do
        count = count -1
        local timeout = 100
        local placed = false
        while not placed do
            timeout = timeout - 1
            local r = Room:random(60,60, 4,15, self)
            if self:hasRoomFor(r) then
                placed = true
                r:paint()
                table.insert(self.rooms,r)
            elseif timeout <= 0 then
                placed = true
            end
        end
    end
    self:connectRooms()
end

function GameRunner:connectRooms()
    for i,r in ipairs(self.rooms) do
        if i > 1 then
            r:connect(self.rooms[i-1])
        end
    end
end

That looks interesting indeed. Here’s what we get with 20 rooms in our 60x60 space:

rand3

rand4

rand5

That’s pretty highly connected but probably OK if you can’t see the whole map. Just for fun let’s try a dozen rooms.

rand6

rand7

That last one is particularly nice, I think, with that long lonely hallway down and across.

This is good stuff. Let’s commit: random connected rooms.

That’s all the learning I can stand …

So let’s sum up and I’ll take a break. I really didn’t do anything remotely like TDD here, but I did throw in a random room creation thing that allowed me to instrument things. But the problem was that I was operating in 0-based indexing mode and Lua operates in 1-indexing. So my tests, checks, asserts, and code were all off by one. Until finally my mind reset and then everything was quickly OK.

The code needs some improvement, and I think that had I been able to think of micro tests for it, it would have likely been driven out in smaller pieces. But we can do that later, or leave it for now.

We need some generalization I think, but we’ll see what we see.

So far, this is going nicely. Some of it, sure, is due to having started once and then started over. But I think the real advantage is that the tile orientation is much better than the geometric orientation with tiles imposed that I tried the first time.

See you next time!

D2.zip