Today I’ll do the first experiment, or a few of them, putting hexagonal tiles into the Dung program. I expect this to get really messy, but with luck, we learn a lot.

I duplicated the Dung program (D2) a few days ago, when I made the initial analysis of whether this was even possible. The new copy is named DX, and I’ve put it under working copy control and committed the 277 files that make up the Dung program. Most of those are asset files, of course.

My initial study was two whole days ago, so I remember nothing about what I figured out. Fortunately, I wrote an article about it and can review my thoughts.

The vague idea that I had was to create a new HexTile class that draws hexagons, and to have Dungeon create a map of HexTile instead of ordinary plain vanilla square tiles. This morning, I think I’ll try making HexTile a subclass of Tile, so that it can inherit the masses of behavior that are independent of shape.

That won’t likely be a good design for the longer term, but today is a day for experimentation. I am prepared to commit code if we get anything promising, but I’m prepared to revert if things go as I expect, i.e. terribly.

Let’s just try copying those tabs into the new DX program. I had thought to set them up as a dependency, but they’ll surely need modification as we go. In Codea, the only reasonable way to do that is to copy them in.

I do it by copy-paste into sublime, in one big file (210 lines) and then creating a single tab in DX. That should be easier to work with and we can break it out again if need be. Fact is, I have so many tabs in this program that I’m often tempted to combine others.

OK, I guess what I’d like to do next is find the place where the game creates the random dungeon, and replace that code with a less random hex dungeon. Main calls GameRunner:createLevel, which is this large method that creates dungeon using the Dungeon class:


function GameRunner:createLevel(count)
    -- determine level
    self.dungeonLevel = self.dungeonLevel + 1
    if self.dungeonLevel > 4 then self.dungeonLevel = 4 end
    
    self:dcPrepare()
    
    -- customize rooms and connections
    self.rooms = self.dungeon:createRandomRooms(count)
    self.dungeon:connectRooms(self.rooms)
    
    -- paint dungeon correctly
    self.dungeon:convertEdgesToWalls()
    -- ready for monsters
    self.monsters = Monsters()
    
    self:placePlayerInRoom1()
    
    -- customize contents
    self:placeWayDown()
    self:placeSpikes(15)
    self:placeLever()
    --self:placeDarkness()
    self:placeNPC()
    self:setupMonsters(6)
    self.keys = self:createThings(Key,5)
    self:createThings(Chest,5)
    self:createLoots(10)
    self:createDecor(30)
    self:createButtons()
    
    -- prepare crawl
    self.cofloater:startCrawl()
    Announcer:sayAndSave(self:crawlMessages(self.dungeonLevel))
    self:startTimers()
    
    self:dcFinalize()
end

Yes, well. I guess I’ll just comment out most of that and then visit Dungeon:createRandomRooms. On a second look, however, I check out this method:

function GameRunner:dcPrepare()
    TileLock = false
    DungeonContents = DungeonContentsCollection()
    self:createNewDungeon()
    local dungeon = self.dungeon
    self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
    dungeon:clearLevel()
    Announcer:clearCache()
end

That’s where the tiles actually get created. So let’s see what happens if we comment out everything except the prepare and the finalize.

GameRunner:535: attempt to index a nil value (field 'player')
stack traceback:
	GameRunner:535: in method 'scaleForPlayer'
	OperatingModes:21: in method 'scaleForLocalMap'
	GameRunner:522: in method 'scaleForLocalMap'
	GameRunner:306: in method 'drawLargeMap'
	GameRunner:285: in method 'draw'
	Main:88: in function 'draw'

We seem to need some player info here. I haven’t even plugged in the hexes yet, I’m just trying to get to a minimal setup.

I comment in self:placePlayerInRoom1() to see what happens. I expect we’ll have to mess with that, because we don’t have any rooms. Good guess, Ron:

GameRunner:463: attempt to index a nil value (field 'rooms')
stack traceback:
	GameRunner:463: in method 'placePlayerInRoom1'
	GameRunner:152: in method 'createLevel'
	Main:43: in function 'setup'
function GameRunner:placePlayerInRoom1()
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    self.player = Player:cloneFrom(self.player, tile,self)
end

Looks like we’d better create at least a fake room. We normally call createRandomRooms. Maybe this isn’t the best hack. Maybe we need to finesse the player some other way. I’ll try this:

function GameRunner:placePlayerInRoom1()
    --[[
    local r1 = self.rooms[1]
    local tile = r1:centerTile()
    self.player = Player:cloneFrom(self.player, tile,self)
    --]]
    local tile = Tile(0,0,TileRoom, self)
    self.player = Player(tile,self)
end

That gives me a new message:

attempt to index a nil value
stack traceback:
	[C]: in for iterator 'for iterator'
	GameRunner:295: in method 'drawButtons'
	GameRunner:288: in method 'draw'
	Main:88: in function 'draw'

Hm, drawButtons, what’s up with that?

function GameRunner:drawButtons()
    for i,b in ipairs(self.buttons) do
        b:draw()
    end
end

Must be a createButtons missing somewhere … sure enough, down in the createLevel. Uncomment it.

Floater:57: attempt to get length of a nil value (field 'buffer')
stack traceback:
	Floater:57: in method 'numberToDisplay'
	Floater:20: in method 'draw'
	GameRunner:333: in method 'drawMessages'
	GameRunner:292: in method 'draw'
	Main:88: in function 'draw'

I was pretty aggressive in commenting out all that initialization, so I’m not surprised that I have to put some back. I might have been more judicious about what to remove, but the more I can remove the happier I’ll be, for this experiment at least.

I think I’d better turn all this back on:

    self:createButtons()
    
    -- prepare crawl
    self.cofloater:startCrawl()
    Announcer:sayAndSave(self:crawlMessages(self.dungeonLevel))
    self:startTimers()
    
    self:dcFinalize()
end

Try again.

GameRunner:563: attempt to index a nil value (field 'monsters')
stack traceback:
	GameRunner:563: in method 'startTimers'
	GameRunner:171: in method 'createLevel'
	Main:43: in function 'setup'

Need at least zero monsters. I find this crock:

function GameRunner:setupMonsters(n)
    self.monsters = Monsters()
    self.monsters:createRandomMonsters(self, 6, self.dungeonLevel, self.playerRoom)
    self.monsters:createHangoutMonsters(self,2, self.wayDown:getTile())
end

This supposedly takes a parameter about the number of monsters, but it’s ignored. We’ll comment out the last two lines of that and just create the Monsters collection.

With those changes, the game comes up and displays a completely useless and random map:

first start

But it’s up. Now we can start creating our new kind of map. The creation action is here:

function GameRunner:dcPrepare()
    TileLock = false
    DungeonContents = DungeonContentsCollection()
    self:createNewDungeon()
    local dungeon = self.dungeon
    self.tiles = dungeon:createTiles(self.tileCountX, self.tileCountY)
    dungeon:clearLevel()
    Announcer:clearCache()
end

The action is in the createTiles and, I suppose, clearLevel.

Now we can move over to the Dungeon class.

function Dungeon:createTiles(tileCountX, tileCountY)
    self.tileCountX = tileCountX
    self.tileCountY = tileCountY
    self.tiles = {}
    for x = 1,tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,tileCountY+1 do
            local tile = Tile:edge(x,y, self.runner)
            self:defineTile(tile)
        end
    end
    return self.tiles
end

function Dungeon:defineTile(aTile)
    assert(not TileLock, "attempt to set tile while locked")
    local pos = aTile:pos()
    self.tiles[pos.x][pos.y] = aTile
end

So. the Dungeon has a collection of Tiles, indexed by x and y. (I’m reminded that there is a two-component way of indexing Hexes, called axial. I bet we’re going to want that.)

Our HexMap knows how to fetch tiles by q,r:

function HexMap:atQR(q,r)
    return self:atCoord(Coord(q, -q-r, r))
end

I wrote that speculatively. I did not, however, write the corresponding put. I’m kind of hoping that won’t matter, but if it does, it’s easily written.

Let’s make a trivial subclass of Tile, HexTile, and see how close we can get.

I should tell you what I’m feeling and expecting at this point, before we go further in.

The changes to get this far have been few, but they have been pretty gross in character, turning off big chunks of capability. That may be OK. We may find that once we get hexes working at the bottom, and can index them, that most of the capability will be easy to adapt. We’ll need new distance measures and such, but that’s OK.

But I’m not feeling comfortable. I’m nervous. I’m feeling like this thing could fly apart in my hands, as if I were disassembling something made of springs and gears. I’m feeling like I could fail.

This is ludicrous, of course, since the worst that can happen is that I’ll decide that converting to Hex isn’t practical, and figure out what, if anything, might have made it practical. And, I’m not even very worried about that. I think this is likely to sort of work.

What I think is more likely is that this experiment, today and maybe a day or two more, will identify capabilities that need to be built correctly, in Coord, Hex, and HexMap, and of course, any new classes we create, like HexTile. I think it’s going to be messy for a while and that then it will work.

But I am nervous. I’ve learned to pay attention to that feeling. I’ve also learned that nervous-making situations are great situations for having someone to pair with. I only have a cat to pair with, however, and they make terrible pair partners.

I’ll just be careful, and pay attention to what happens.

Let’s change that create method, by intention, from this:

function Dungeon:createTiles(tileCountX, tileCountY)
    self.tileCountX = tileCountX
    self.tileCountY = tileCountY
    self.tiles = {}
    for x = 1,tileCountX+1 do
        self.tiles[x] = {}
        for y = 1,tileCountY+1 do
            local tile = Tile:edge(x,y, self.runner)
            self:defineTile(tile)
        end
    end
    return self.tiles
end

function Dungeon:defineTile(aTile)
    assert(not TileLock, "attempt to set tile while locked")
    local pos = aTile:pos()
    self.tiles[pos.x][pos.y] = aTile
end

To this:

function Dungeon:createTiles(tileCountX, tileCountY)
    self.tiles = HexMap()
    HexTile:createRing(10, self.tiles)
    return self.tiles
end

No. A less difficult idea might be this:

function Dungeon:createTiles(tileCountX, tileCountY)
    local map = Hex:createRing(10)
    self.tiles = map
    return self.tiles
end

I’ll probably have to make Hex a subclass of Tile or something but let’s see what this does.

Are you wondering why I created the map as a local and then set it into self.tiles? That’s because I think something will need to go in between those to statements, to get ready for tiles. So I just expressed that notion, very weakly, with those two statements.

Let’s see what it does. Nothing good, I’m sure.

HexClasses:202: attempt to index a nil value (local 'map')
stack traceback:
	HexClasses:202: in method 'createRing'
	Dungeon:226: in method 'createTiles'
	Tests:25: in field '_before'
	codeaUnit:44: in method 'test'
	Tests:36: in local 'allTests'
	codeaUnit:16: in method 'describe'
	Tests:12: in function 'testDungeon2'
	[string "testDungeon2()"]:1: in main chunk
	codeaUnit:139: in field 'execute'
	Tests:625: in function 'runCodeaUnitTests'
	Main:8: in function 'setup'

I called the wrong method. Should be createMap:

function Dungeon:createTiles(tileCountX, tileCountY)
    local map = Hex:createMap(10)
    self.tiles = map
    return self.tiles
end

A million tests fail. Can’t do it this way. I wish I had a revert point. Rename our new method createHexTiles and put the old createTiles back. Tests all run again.

Now let’s see if I can change the main dungeon creation to call createHexTiles. A few tests break, but I can ignore those.

Now I get a yellow display and this trace:

Dungeon:352: attempt to compare nil with number
stack traceback:
	Dungeon:352: in function <Dungeon:351>
	(...tail calls...)
	Tile:331: in method 'illuminateLine'
	Tile:311: in method 'illuminate'
	Player:54: in field 'init'
	... false
    end

    setmetatable(c, mt)
    return c
end:24: in global 'Player'
	GameRunner:472: in method 'placePlayerInRoom1'
	GameRunner:152: in method 'createLevel'
	Main:43: in function 'setup'

Yes, right, we’re trying to illuminate tiles. That will definitely need changing. Let’s make illuminate just return.

This gives me a yellow display, and when I touch the screen, nothing bad happens. It’s black, and the crawl goes. It goes very fast. I suspect I’ve not adjusted its speed for this new iPad.

OK, let’s see what we have to do to get something to display. I’d have expected some kind of display action. What does draw do?

I comment out the yellow display bit and observe that we “just” call Runner:draw:

function GameRunner:draw()
    font("Optima-BoldItalic")
    self:drawLargeMap()
    self:drawTinyMapOnTopOfLargeMap()
    self:drawMapContents()
    self:drawButtons()
    self:drawInventory()
    self:drawPlayerOnSmallMap()
    self:drawMessages()
end

Time for some grotesque hackery here. I think I just want the drawLargeMap and buttons, oh, and messages. I’ll comment the others out:

function GameRunner:draw()
    font("Optima-BoldItalic")
    self:drawLargeMap()
    --self:drawTinyMapOnTopOfLargeMap()
    --self:drawMapContents()
    self:drawButtons()
    --self:drawInventory()
    --self:drawPlayerOnSmallMap()
    self:drawMessages()
end

Now let’s see what is wrong with drawLargeMap.

function GameRunner:drawLargeMap()
    pushMatrix()
    self:scaleForLocalMap()
    self:drawMap(false)
    popMatrix()
end

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

Well, there’s yer problem, right there. We can’t loop over our HexMap that way, no sir. And of course our scaling is surely way off as well.

Here’s how we drew our hexes in the D3 experiment:

    local center = Hexes:atXYZ(0,0,0)
    center:fill(color(194, 104, 99))
    for k,hex in pairs(Hexes.tab) do
        pushMatrix()
        local coord = hex:screenPos()
        local cx = coord.x
        local cy = coord.y
        translate(cx,cy)
        for rot = 0,5 do
            local bl = hex:boundaryLine(rot)
            line(table.unpack(bl))
        end
        popMatrix()
    end

Hexes is a HexMap. But let’s do something more nearly right (maybe) and tell the HexMap to draw itself. My first attempt that actually draws something is this:

function HexMap:draw()
    pushMatrix()
    pushStyle()
    translate(WIDTH/2, HEIGHT/2)
    scale(30)
    strokeWidth(1)
    stroke(255)
    for k,hex in pairs(self.tab) do
        pushMatrix()
        local coord = hex:screenPos()
        local cx = coord.x
        local cy = coord.y
        translate(cx,cy)
        for rot = 0,5 do
            local bl = hex:boundaryLine(rot)
            line(table.unpack(bl))
        end
        popMatrix()
    end
    popStyle()
    popMatrix()
end

We get this:

top-right

So that’s good news. I didn’t do anything with the scaleForLargeMap. I comment it out, try again.

big hex

I know what that is, it’s the strokeWidth. Recall that our Hex is very tiny and scales oddly. Change to match the experimental draw in the D3 spike:

function HexMap:draw()
    pushMatrix()
    pushStyle()
    translate(WIDTH/2, HEIGHT/2)
    local sc = 30
    scale(sc)
    strokeWidth(1.0/sc)
    stroke(255)
    for k,hex in pairs(self.tab) do
        pushMatrix()
        local coord = hex:screenPos()
        local cx = coord.x
        local cy = coord.y
        translate(cx,cy)
        for rot = 0,5 do
            local bl = hex:boundaryLine(rot)
            line(table.unpack(bl))
        end
        popMatrix()
    end
    popStyle()
    popMatrix()
end

And here’s our picture:

good hex

That’s what I call a rudimentary hex map. I’m going to commit this code, just in case I decide to work forward. Commit: can draw big hex map. Many things turned off.

Now let’s assess what we’ve learned.

When You Assess …

No, that’s not the right quote. Anyway. We modified Main, GameRunner, Dungeon, and Tile. We added in Coord, Hex, and HexMap. We added a draw method to HexMap to draw the hexes. What have we learned?

Operationally, we haven’t learned much. We didn’t subclass the Tile class, we just went around it, so we can’t be certain what will happen when we try to look into our tiles. We’ll have to normalize the coordinates somehow in order to do that. I think that the best thing will be to make a roughly rectangular array of Hexes, so that we can use our tileX and tileY coordinates without regard to whether they are really x,y or q,r. We’ll probably want to create a TileMap for square tiles with the same protocol as HexMap. And we’ll push behavior down into those as need be, such as the drawing logic we did this morning.

TileMap should be easy, a morning or two.

Since we know that they want to be able to have both square and hex tiles in the same game, we’ll need to rig up the tests and the game’s startup logic to allow us to select what it does. Perhaps the ideal thing to shoot for is that the game starts up square as usual, but there’s a button we can press, or a toggle to set that has it create the hex map.

There will of course be lots of logic involved in creating a hex map that is fun to play. Our first stories will involve just getting player and monsters and treasures into a wide-open map. Then we’ll have to work out obstacles and terrain and whatever hex maps are supposed to have.

And of course there’ll be graphics. I think we’ll have to invent something if we are to put a pictorial pattern into our hexagonal tile flooring, as we do for our square floors. Codea doesn’t know how to fill a hex with a picture.

If they were to ask me right now how long I think it will take to put hexes into the game, what would I say? I don’t know what I think until I hear what I say.

What I say, and therefore think is …

We’ll need to create some adapter classes to embed hexes into the logic. So far, I’m sure we’ll need a new TileMap class that should be fairly simple, a day’s work perhaps, and I think we’ll need two or three other adapters to bring hexes and squares to the same level.

I think we can get all the players, monsters, and treasures laid out in a wide-open hex area in two weeks of mornings, one week if we are more fortunate than I think we have any right to be. There’s no “try to make it one”. It’ll take as long as it takes and I think it’ll probably be no more than two and we might get lucky. There’s no chance that we’ll get much smarter in just a week.

Then we’ll need to do something that allows us to create walls and other obstacles in the hex space, such as we have in the square tiles. It’s just barely possible that we can have a container class that knows those facts about a tile inside it, which could be either square or hex. That would be ideal, but it still won’t really solve the problem of arranging interesting spaces in hexes. If you try to draw some rooms on hex paper, you’ll see what I mean. Square rooms just aren’t on in hex format.

In fact, it would be good if we could sit with the art people and draw up some sample maps just to begin to get a sense of what’s needed. I can imagine that a day or two of that would be helpful.

And then there’s the real art. You’ll have to decide whether you want to use our current player and monster pictures or not. I think they’ll look better than pure top-down pictures, which will all look a lot like ovals. Same with treasures and chests. I think we’ll assume that we’ll reuse those and put in such new pictures as the art people may create.

But there’s the floor or ground to think about. We do not have a way to fill a hexagon with an image: Codea assumes all images are rectangular. We’ll need a few days, maybe only two, to figure out what our limitations are. We can certainly put anything onto a hex that will fit in a rectangle that fits inside the hex. Filling out to the points and edges, we just don’t know, but can find out.

We also have to think about creating random maps. Our rectangular rooms and straight hallways don’t translate well into hexagons, so we’ll have to create something new. I’d guess that could take a week, but I’d want to experiment for a day before firming that up.

There’s illumination to think about. We’ll need new logic for that, and possibly new logic for things like the Pathfinder monster who leads you to the WayDown. The reason is that we’ll have to implement line drawing and flood filling in the hex. This is known tech, but we don’t have any code to do it. We do know where to find examples, so it’s a few days’ work but not at risk.

Netting it all out, what I think right now is that if we go heads-down on this, we can get to the point where we can draw our current tiled maps and play on those, and draw very simple large hex maps and play on those, in a month or less. However, right now, I don’t see a good way to get a better guess in less than a week or two, because there’s about a week of grunt work getting the hex map to really work.

I’ll know more after tomorrow’s session, and maybe I can identify risks at that point, and propose a path forward that shows reasonable results every day or so. For now, I feel sure that we can do it, and that it’s around a month of the whole team’s time. (The whole team is me. If we had a few of me, it might be shorter, but right now, there’s not a great deal to go parallel on anyway.)

So, there we are.

Overall, a good start: we can draw a hex map inside the game.

A fairly stressful morning, for some reason I can’t really explain, because nothing truly tragic really happened. There’s a lot of code commented out, but I feel that most of it can be adapted pretty quickly if I can figure out an adapter setup that will run on top of either squares or hexes. I think that’s no more than indexing and forwarding, so it should be pretty easy.

I don’t have a specific plan for tomorrow or the upcoming days. I’m going to let today’s experience perk in my mind a bit, and see what tomorrow brings when the sun comes up.

See you then, I hope!