Current plan is to move forward from the little tiled-space experiment, evolving it into a playable game. If I’m right, we should see faster progress. It could happen.

Conventional advice for a spike experiment like yesterday’s is to throw it away and start over. I think there’s so little there that we can safely begin to evolve it. I’m hopeful, indeed fairly sure, that we’ll make more progress with this new scheme than with the preceding one. The main reason for that, if it happens, will be the more coherent model of a tiled space.

The previous version had rooms and doors and hallway rooms and coloring algorithms and such, and all those objects weren’t helping us. It’s as if we needed to paint vertical striped on the wall and someone had put diagonal masking tape stripes on it. The masking tape just wouldn’t help. I felt that way with the previous version’s objects.

I wonder whether most programmers get a chance to develop a sense of when the design is helping, and when it isn’t. If you’re dropped into a giant pool of legacy code, and the objects aren’t as helpful as they could be, you might never know what it feels like to work with objects that are aligned with the stripes you’re trying to paint.

I do hope that almost everyone has some experience of working in code and having the work go well. Maybe this only happens in little personal projects, or at other random times, but the feeling that things are going well can be there. Hold on to that feeling, and notice when you’re moving away from it. It’s a sign that there’s something wrong, and noting it may allow you to improve the code, and the situation.

In this case, I’ve taken the radical step of throwing away the work of a week and a half, done over the course of a month. If it was a month of full time work, I’d feel worse about it, but it might still be the right thing to do.

Is it the right thing? We’ll find out. Let’s get started.

Organizing

The code so far is all in one tab. I’ll start by moving the Tile and Room classes into separate tabs. Then we’ll review what’s going on.

That goes smoothly. I’d best set up WorkingCopy so that I can do some versioning.

Setting Up Working Copy

  1. Init new repository, name it Dung-2;
  2. Go back to repo list, long press Dung-2;
  3. Select Share;
  4. Select package sync with Codea logo;
  5. Scroll through Codea programs find D2, select it.
  6. Connected, do initial commit.

Nothing to it. I always forget how, though, so I try to leave notes on how I do it in the articles. Maybe I can find them next time I look.

Moving right along …

Main setup looks like this:

function setup()
    if CodeaUnit then 
        codeaTestsVisible(true)
        runCodeaUnitTests() 
    end
    TileSize = 16
    TileCount = 60
    Tiles = {}
    for x = 1,TileCount do
        Tiles[x] = {}
        for y = 1,TileCount do
            local tile = Tile:edge(x,y)
            Tiles[x][y] = tile
        end
    end
    Room(5,5,10,10)
    Room(20,20,4,5)
    Room(25,10,8,15)
    Room(50,50,10,10)
end

We’ll probably want a GameRunner object. Let’s create one now and give most of that work to it.

-- GameRunner
-- RJ 20201202

GameRunner = class()

function GameRunner:init()
end

function GameRunner:draw()
end

We’ll let GameRunner own the tiles and rooms. I think for now I’ll just put that stuff in the init and let it be.

First, I just paste it in directly from Main, replacing it there with calls to GameRunner:

function setup()
    if CodeaUnit then 
        codeaTestsVisible(true)
        runCodeaUnitTests() 
    end
    Runner = GameRunner()
end

function draw()
    if CodeaUnit then showCodeaUnitTests() end
    Runner:draw()
end

Everything still works at this point but let’s clean things up. This took a bit more cleaning than I’d hope for, and has identified an issue already. Here’s where we are:

GameRunner = class()

TileSize = 16

function GameRunner:init(count)
    self.tileCount = count or 60
    self.tileSize = 16
    self.tiles = {}
    for x = 1,self.tileCount do
        self.tiles[x] = {}
        for y = 1,self.tileCount do
            local tile = Tile:edge(x,y)
            self.tiles[x][y] = tile
        end
    end
    Room(5,5,10,10, self.tiles)
    Room(20,20,4,5, self.tiles)
    Room(25,10,8,15, self.tiles)
    Room(50,50,10,10, self.tiles)
end

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for x = 1,self.tileCount do
        for y = 1,self.tileCount do
            self.tiles[x][y]:draw()
        end
    end
end

I’ve had to make TileSize global, and had to pass the tiles collection to Room(). I’d have preferred not to have a global already, nor to have passed the live tile collection around. Let’s do this with a callback instead:

...
    Room(5,5,10,10, self)
    Room(20,20,4,5, self)
    Room(25,10,8,15, self)
    Room(50,50,10,10, self)

Now we’ll have the room creation tell us what tiles it’s setting:

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

And we’ll teach GameRunner to set the tiles:

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

Why does the Room need to know how large the tiles are? Oh, it’s Tile, in its draw function:

function Tile:draw()
    pushMatrix()
    pushStyle()
    fill(self:fillColor())
    rect(self:gx(),self:gy(),TileSize)
    popStyle()
    popMatrix()
end

I guess tiles do deserve to know their size. But both GameRunner and Room presently make tiles. Let’s allow only GameRunner to make them. And don’t they know their x and y? Maybe we can clean up Room a bit.

No, let’s just add a local value to Tile. This isn’t something we’ll be changing at run time.

Tile = class()

local TileWall = 1
local TileRoom = 2
local TileEdge = 3
local TileSize = 16

For now, I’ll call it legit for people to make tiles by their creation names room, wall, and edge.

Time to commit: refactoring for isolation of concerns.

I have to leave for an appointment soon, so let’s quickly look at room creation. Right now, we create them and throw them away. What’s coming up for them may include

  • Creating rooms that don’t overlap other rooms;
  • Digging hallways between rooms;
  • Rooms having special contents that can’t be represented inside tiles;

These needs speak to saving the rooms in a collection in GameRunner:

    self.rooms = {}
    table.insert(self.rooms, Room(5,5,10,10, self))
    table.insert(self.rooms, Room(20,20,4,5, self))
    table.insert(self.rooms, Room(25,10,8,15, self))
    table.insert(self.rooms, Room(50,50,10,10, self))

Commit: rooms saved in GameRunner table.

I’d like to see whether I can remove the concern about tileCount from draw. Presently:

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for x = 1,self.tileCount do
        for y = 1,self.tileCount do
            self.tiles[x][y]:draw()
        end
    end
end

How about:

function GameRunner:draw()
    fill(0)
    stroke(255)
    strokeWidth(1)
    for i,row in ipairs(self.tiles) do
        for j,tile in ipairs(row) do
            tile:draw()
        end
    end
end

That works just fine, as it should. It means that there are two fewer references to the tile count, and I think that’s a good thing.

I noticed a tiny bit of duplication:

    self.tiles = {}
    for x = 1,self.tileCount do
        self.tiles[x] = {}
        for y = 1,self.tileCount do
            local tile = Tile:edge(x,y)
            self.tiles[x][y] = tile
        end
    end

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

Given that we have an official method for storing tiles into the table, we ought to use it. Then, if we ever change how we do that, there will only be one place to fix. And, in my view, it’s a bit less confusing. Otherwise we might ask “why is this done in two places”. I need to minimize my confusion. So:

    self.tiles = {}
    for x = 1,self.tileCount do
        self.tiles[x] = {}
        for y = 1,self.tileCount do
            local tile = Tile:edge(x,y)
            self:setTile(tile)
        end
    end

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

Note that I also removed the x y parms from the method. The tile knows where it goes. I’ll fix Room, but the extra parms are just ignored anyway.

function Room:init(x,y,w,h, runner)
    self.x1 = x
    self.y1 = y
    self.x2 = x + w
    self.y2 = y + h
    -- 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

You know, if we would just pass in what kind of tile we wanted, maybe we could avoid knowing much of anything about tiles here. I’ll look at that when I return. For now, commit: simplify and centralize tile creation and drawing.

Hold on tight, I’ll be back soon.

Soon

I had a random idea. Part of what makes this tile-based design easy to work with is that the entire universe will be just an array of tiles. (I’m predicting that it’ll be easy, but I’m somewhat experienced with programming, and I’m pretty sure.) Anyway, the idea is this:

Most of the universe will be “edge” tiles. We can think of them as the rock from which the dungeon has been dug out. (Out from which the dungeon has been dug?) I’m not sure at this moment what the percentage of edge to non-edge will be, but if it’s large, we could use a storage approach that only stores the non-edge tiles, and defaults the rest.

If storage is a problem. It may not be, last night I created a single image in Codea that was 500 by 500 tiles wide. Far bigger than could fit on the screen. It was a bit slow to start up but worked fine as far as I could tell.

But anyway, cool idea.

We might even get to use the Flyweight pattern. No one gets to use Flyweight.

But I digress. The two main tasks before me today are to dig corridors between the rooms, and to allocate rooms randomly without overlap. Let’s do the corridors first for no particular reason.

Corridors

The scheme proposed in the Roguelike tutorial I referred to is to dig a corridor from the room just created to the room created just before it. I think we’ll see that that turns out to be more interesting than it may seem at first thought.

The corridors are dug by first choosing whether to start out in a horizontal direction or vertical. Either way, you dig in that horizontal-vertical direction from the current room center to the center coordinate of the previous room, then from that point in the vertical-horizontal direction to the center of the previous room. As I say, I think this is going to work better than intuition may suggest.

I imagine that I should TDD this, but honestly I don’t see a very interesting test to write. Oh, heck, OK, I’ll try it.

One big question in my mind is who should be in charge of digging the paths. For now, I’ll let it be GameRunner, which is kind of in charge of setting things up.

        _:test("Horizontal corridor", function()
            local runner = GameRunner()
            runner:horizontalCorridor( 10,30, 50)
        end)

I intend that method to take parameters x1, x2, and y, and to dig out the corresponding cells from x1-x2 at position y. Now we “just” have to check them. Maybe like this?

        _:test("Horizontal corridor", function()
            local tile
            local runner = GameRunner()
            runner:horizontalCorridor( 10,30, 50)
            tile = runner:getTile(9,50)
            _:expect(tile:isEdge()).is(true)
        end)

We don’t have the isEdge method either. At this moment, I don’t want to pull the kind variable out of the tile, even in a test. I’ll run the test and fix the complaints.

1: Horizontal corridor -- Tests:18: attempt to call a nil value (method 'horizontalCorridor')

No surprise. I’m just going to implement this rather than futz around with “fake it till you make it” or such. Well maybe part way:

function GameRunner:horizontalCorridor(fromX, toX, y)
    
end

This will force out the isEdge function, and also:

1: Horizontal corridor -- Tests:19: attempt to call a nil value (method 'getTile')

I already forgot I needed that. That’s the power of putting things into tests: you don’t have to remember so much.

function GameRunner:getTile(x,y)
    return self.tiles[x][y]
end
1: Horizontal corridor -- Tests:20: attempt to call a nil value (method 'isEdge')

There we go. And:

function Tile:isEdge()
    return self.kind == TileEdge
end

function Tile:isRoom()
    return self.kind == TileRoom
end

I wrote isRoom speculatively. But I didn’t do isWall. In the longer term I’m not even sure we’ll need it.

Now the test succeeds, because all the tiles are edge tiles, and we do have tiles, because GameRunner creates them automatically. (That may turn out to be a bad idea. We’ll see.)

Now to extend the test. I just went for the whole bear:

        _:test("Horizontal corridor", function()
            local tile
            local msg
            local runner = GameRunner()
            runner:horizontalCorridor( 10,30, 50)
            tile = runner:getTile(9,50)
            _:expect(tile:isEdge()).is(true)
            tile = runner:getTile(31,50)
            _:expect(tile:isEdge()).is(true)
            for x = 10,30 do
                tile =  runner:getTile(x,50)
                msg = string.format("%d %d", x, 50)
                _:expect(tile:isRoom(), msg).is(true)
            end
        end)

This of course fails a million times or at least a lot. We need:

function GameRunner:horizontalCorridor(fromX, toX, y)
    for x = fromX,toX do
        self:setTile(Tile:room(x,y))
    end
end

So you can see why I didn’t think this worthy of a test. But I just took a short break and thought of a test this can’t pass:

        _:test("backward horizontal corridor", function()
            local tile
            local msg
            local runner = GameRunner()
            runner:horizontalCorridor( 30,10, 50)
            for x = 10,30 do
                tile =  runner:getTile(x,50)
                msg = string.format("%d %d", x, 50)
                _:expect(tile:isRoom(), msg).is(true)
            end
        end)

I reversed the order of the x’s. We have an actual need for this, because we’re just going to plug in two room centers and the rooms could be above, below, left, right of each other. So:

function GameRunner:horizontalCorridor(fromX, toX, y)
    fromX,toX = math.min(fromX,toX), math.max(fromX,toX)
    for x = fromX,toX do
        self:setTile(Tile:room(x,y))
    end
end

There. Are you happy now? I’m not, because of all those asserts in a loop. We could write a little test-auxiliary function to check a range for a type, but it might get weird. Oh well, in for a penny. I’m going to try to make this work:

        _:test("backward horizontal corridor", function()
            local tile
            local msg
            local r,x,y
            local runner = GameRunner()
            runner:horizontalCorridor( 30,10, 50)
            r,x,y = checkRange(runner, 10,50, 3,50, Tile.isRoom)
            msg = string.format("%d %d", x, y)
            _:expect(r,msg).is(true)
        end)

The idea is that we’ll check the range and only assert on the first error we find. This might work.

function checkRange(runner, x1, y1, x2, y2, checkFunction)
    for x = x1,x2 do
        for y = y1,y2 do
            local t = runner:getTile(x,y)
            local r = checkFunction(t)
            if not r then return r, x, y end
        end
    end
    return true,0,0
end

Let me see if this is going to work before I go to the trouble of explaining it. And it does.

The function checkRange takes a tile area from x1,y1 to x2,y2. That’s because I plan to use it for vertical and other things. And it accepts a pointer to a method on Tile, in our case isRoom. The checkRange function loops over its whole range, calling the checkFunction on each tile. If the function returns false, checkRange returns false also, and the x and y it’s working on.

The expect in the test will either say OK, or fail on the first cell that doesn’t pass the check.

This is a bit fancy but it cuts down on the trash in the unit test console.

As a check on the check, I extended the call to include 31 and got:

2: backward horizontal corridor 31 50 -- Actual: false, Expected: true

So I’m calling that working. Next we’ll do the vertical, I guess. Will it be OK with you if I just do the reverse test this time?

        _:test("backward vertical corridor", function()
            local tile
            local msg
            local r,x,y
            local runner = GameRunner()
            runner:verticalCorridor( 15,45, 20) -- y1, y2, x
            r,x,y = checkRange(runner, 20,15, 20,45, Tile.isRoom)
            msg = string.format("%d %d", x, y)
            _:expect(r,msg).is(true)
        end)
3: backward vertical corridor -- Tests:47: attempt to call a nil value (method 'verticalCorridor')
function GameRunner:verticalCorridor(fromY, toY, x)
    fromY,toY = math.min(fromY,toY), math.max(fromY,toY)
    for y = fromY, toY do
        self:setTile(Tile:room(x,y))
    end
end

Test passes.

Now to connect some rooms, I guess. This testing stuff is grueling but probably worth it.

        _:test("connect rooms h then v", function()
            local tile
            local msg
            local r,x,y
            local runner = GameRunner()
            local r1 = Room(10,10,5,5, runner)
            local r1x, r1y = r1:center()
            _:expect(r1x).is(12)
            local r2 = Room(30,30, 6,6, runner)
            local r2x,r2y = r2:center()
            _:expect(r2x).is(33)
        end)

I’ll need to get the room center so I figured I’d start there.

I don’t like having to pass in the runner, but I don’t have a better idea.

4: connect rooms h then v -- Tests:59: attempt to call a nil value (method 'center')
function Room:center()
    return (self.x2-self.x1)//2, (self.y2-self.y1)//2
end

We want to be sure to return integer tile coordinates here. No fractions in that array. Hmm. That’s wrong. This is right:

function Room:center()
    return (self.x2+self.x1)//2, (self.y2+self.y1)//2
end

Test passes so far. Let’s call the path digging function:

        _:test("connect rooms h then v", function()
            local tile
            local msg
            local r,x,y
            local runner = GameRunner()
            local r1 = Room(10,10,5,5, runner)
            local r1x, r1y = r1:center()
            _:expect(r1x).is(12)
            _:expect(r1y).is(12)
            local r2 = Room(30,30, 6,6, runner)
            local r2x,r2y = r2:center()
            _:expect(r2x).is(33)
            _:expect(r2y).is(33)
            r2:hvCorridor(r1)
        end)

I think this is too symmetric. With equal x’s and y’s we might get something wrong. I’ll shift the rooms a bit:

        _:test("connect rooms h then v", function()
            local tile
            local msg
            local r,x,y
            local runner = GameRunner()
            local r1 = Room(10,15,5,5, runner)
            local r1x, r1y = r1:center()
            _:expect(r1x).is(12)
            _:expect(r1y).is(17)
            local r2 = Room(30,40, 6,6, runner)
            local r2x,r2y = r2:center()
            _:expect(r2x).is(33)
            _:expect(r2y).is(43)
            r2:hvCorridor(r1)
        end)

There, that’s a little more random. Now I need some checks for the path. It should go from r2 center horizontally to r1 center x and then vertically from there to r1 center. Two calls to our checker should do it.

            checkRange(runner, r2x,r2y, r1x, r2y, Tile.isRoom)
            checkRange(runner, r1x,r2y, r1x, r1y, Tile.isRoom)

Is this right? I hope so. Even in tiles the coordinates are a bit tricky to think about. Let’s see about coding hvCorridor:

Ah. We need a runner to do the digging. Unless we want rooms to do the digging. Would that be better? They already dig themselves out. Oh but they need a runner anyway, to find the tiles. Maybe rooms should save their runner. Let’s try that.

function Room:hvCorridor(aRoom)
    local r1x,r1y = self:center()
    local r2x,r2y = aRoom:center()
    self.runner:horizontalCorridor(r1x,r2x,r2y)
    self.runner:verticalCorridor(r2y,r1y,r1x)
end

I am not entirely fond of those calls but they won’t be used often. Just here, I suspect. Which may argue for consolidating them somehow. But let’s make them work.

What is surprising is that the test passed. I want to set up the visible dungeon to draw a path so that I can see with my own eyes.

Hm speaking of that, here’s GameRunner init:

function GameRunner:init(count)
    self.tileCount = count or 60
    self.tileSize = 16
    self.tiles = {}
    for x = 1,self.tileCount do
        self.tiles[x] = {}
        for y = 1,self.tileCount do
            local tile = Tile:edge(x,y)
            self:setTile(tile)
        end
    end
    self.rooms = {}
    table.insert(self.rooms, Room(5,5,10,10, self))
    table.insert(self.rooms, Room(20,20,4,5, self))
    table.insert(self.rooms, Room(25,10,8,15, self))
    table.insert(self.rooms, Room(50,50,10,10, self))
end

We really can’t have the GameRunner slamming rooms into our test tiles, can we? Let’s move the room creation out:

function GameRunner:init(count)
    self.tileCount = count or 60
    self.tileSize = 16
    self.tiles = {}
    for x = 1,self.tileCount do
        self.tiles[x] = {}
        for y = 1,self.tileCount do
            local tile = Tile:edge(x,y)
            self:setTile(tile)
        end
    end
end

function GameRunner:createTestRooms()
    self.rooms = {}
    table.insert(self.rooms, Room(5,5,10,10, self))
    table.insert(self.rooms, Room(20,20,4,5, self))
    table.insert(self.rooms, Room(25,10,8,15, self))
    table.insert(self.rooms, Room(50,50,10,10, self))
end

And we’ll call that from setup I guess. Tests all good, and the rooms still look right. Let’s connect some rooms.

function GameRunner:createTestRooms()
    self.rooms = {}
    table.insert(self.rooms, Room(5,5,10,10, self))
    table.insert(self.rooms, Room(20,20,4,5, self))
    table.insert(self.rooms, Room(25,10,8,15, self))
    table.insert(self.rooms, Room(50,50,10,10, self))
    self.rooms[4]:hvCorridor(self.rooms[1])
end

This provides this pic:

first connect

That slightly surprised me, because I was thinking we’d go horizontal from 4 and then down to 1. But it went the other way. What if I reverse those?

second connect

That’s more like what I envisioned. It’s time to break now, so I’ll think that out next time around. But I want to connect some more rooms:

function GameRunner:createTestRooms()
    self.rooms = {}
    table.insert(self.rooms, Room(5,5,10,10, self))
    table.insert(self.rooms, Room(20,20,4,5, self))
    table.insert(self.rooms, Room(25,10,8,15, self))
    table.insert(self.rooms, Room(50,50,10,10, self))
    self.rooms[2]:hvCorridor(self.rooms[1])
    self.rooms[3]:hvCorridor(self.rooms[2])
    self.rooms[4]:hvCorridor(self.rooms[1])
end

This is the order I expect to do, with room n connecting to room n-1. Even with just the hv corridor implemented, you can see why I think the results will be more interesting than we might have thought:

all connected

If we connect them that way, end to end, when a path goes across another room, it just cuts right through. The example at the bottom is particularly nice. The big central room just has a hallway passing right through the lower edge of it.

One might wonder about the grey “wall” tiles, and whether we’ll miss them. My plan is not to display them in any particularly different way at all, resulting in a map like this:

bwmap

I just display the wall tiles in grey as an aid in checking room placement and logic. An edge and a wall will behave the same.

Commit: hv paths work.

Summing Up

So that went nicely, didn’t it? We went from a spike to ability to place rooms and draw corridors (though we want some more options there). And the tests, though I resisted doing them, actually helped me focus on the details of the carving of paths. So I’m glad I did them.

See you next time!

D2.zip