Dungeon 24
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
- Init new repository, name it Dung-2;
- Go back to repo list, long press Dung-2;
- Select Share;
- Select package sync with Codea logo;
- Scroll through Codea programs find D2, select it.
- 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 tidy things up. This took a bit more fixing up 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 improve 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:
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?
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:
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:
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!