Dungeon 242
Can I make a hex map in the game yet, or do I need more than a couple of changes? I don’t know. Let’s try to find out. Spoiler: Not great, not horrible.
I really don’t know whether I’ll actually do anything game-like on the hex tiles, once they are working. I do think there are a couple of interesting issues with them, not least getting attractive hex tiles to display, since Codea is relentlessly rectangular about displaying things. Even if I make rectangular images with transparent pixels beyond the bounds of the hex, will they display correctly, or will adjacent tiles mess with each other?
But the big challenge here is making the game, which did not expect a hex map, support a hex map, however ugly or attractive it may be. It looks like we can accomplish that fairly readily, and therefore my theory of programming is not yet shown to be utter folderol. (I was going to use another word there.)
Let’s talk about the basics of displaying a hex map instead of a square one. Here are the issues that come to mind, in no particular order:
- Screen Position
- Hex tiles map to the screen differently, all that square root of three stuff. The Tile object now handles getting the screen position, and it will, I assume, have to forward that to the Map or a MapPoint. At the vague level of understanding I have at the moment, talking to the MapPoint may be better, because they could readily know whether they are hex or square.
- Drawing
- The Tile draws its sprites and contents. It may be necessary for it to have two draw methods, one for squares and one for hexes. In early days, I expect we’ll at least have to draw the raw hexagon that we used in the spike, just to see what the map looks like.
-
The obvious, and wrong way to do this would be to ask the MapPoint whether it’s hex or square and have an if statement or similar dispatch in Tile. A better way would be to forward the draw to the MapPoint (which represents the space for a tile), and to have the MapPoint call back for us to draw the details. We might provide that callback when we create the Map, as we do with the signal that tells the Map what kind of Map to be.
There are many other issues involved in making the hex map playable, all those methods that calculate distances and so on. Mostly all that will come down to forwarding messages to the Map, and, probably, to relying on our MapPoint arithmetic to do the right thing. I don’t think we need to do any of that in order to get the map to display.
Something will surely explode. Let’s start finding out what.
Where to Start?
That’s always the question. I generally start by looking around, and like the guy looking for his keys, I try to look where the light is good. This morning, I think I’ll look at how Tiles get drawn:
Main calls Runner:draw
. GameRunner calls drawLargeMap
and drawTinyMapOnTopOfLargeMap
.
function GameRunner:drawLargeMap()
pushMatrix()
self:scaleForLocalMap()
self:drawMap(false)
popMatrix()
end
function GameRunner:drawMap(tiny)
fill(0)
stroke(255)
strokeWidth(1)
self.dungeon:drawMap(tiny)
end
function GameRunner:drawTinyMapOnTopOfLargeMap()
OperatingMode:drawTinyMap(self)
end
function GameRunner:drawTinyMap()
pushMatrix()
self:scaleForTinyMap()
Runner:drawMap(true)
popMatrix()
end
So it all comes down to Dungeon:drawMap
:
function Dungeon:drawMap(tiny)
for pos,tile in pairs(self.map.tab) do
tile:draw(tiny)
end
end
And down to Tile. Yummy:
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:drawSprites(tiny)
popStyle()
popMatrix()
end
function Tile:drawSprites(tiny)
local center = self:graphicCenter()
if tiny then
self:drawMapCell(center)
else
self:drawLargeSprite(center)
end
end
function Tile:drawLargeSprite(center)
-- we have to draw something: we don't clear background. Maybe we should.
local sp = self:getSprite(self:pos(), false)
pushMatrix()
pushStyle()
textMode(CENTER)
translate(center.x,center.y)
if not self.currentlyVisible then tint(0) end
if self:canBeDoor() then
sp = DoorSprite
if self.doorDirection == "EW" then
rotate(90)
end
end
sp:draw()
popStyle()
popMatrix()
end
OK, all that comes down to getting graphicCenter()
and then drawing whatever one wants to draw.
Now I want to know who, if anyone, knows whether we are on a hex or square map.
function Dungeon:createTiles(tileCountX, tileCountY)
self.tileCountX = tileCountX
self.tileCountY = tileCountY
self.map = Map:cartesianMap(self.tileCountX+1,self.tileCountY+1, Tile,TileEdge, self. runner)
self.tiles = {}
for x = 1,tileCountX+1 do
self.tiles[x] = {}
for y = 1,tileCountY+1 do
local tile = self.map:atXYZ(x,0,y)
self:defineTile(tile)
end
end
end
function Map:hexMap(xWidth, zHeight, klass, ...)
return Map(xWidth, zHeight, hex, klass, ...)
end
function Map:cartesianMap(xWidth, zHeight, klass, ...)
return Map(xWidth, zHeight, cartesian, klass, ...)
end
function Map:init(xWidth, zHeight, mapType, klass, ...)
assert(mapType == cartesian or mapType == hex, "MapType must be cartesian or hex")
self.mapType = mapType
self:createRectangle(xWidth, zHeight, klass, ...)
end
function Map:createRectangle(xWidth, zHeight, klass, ...)
self.tab = {}
for z = 1,zHeight do
for x = 1,xWidth do
local pt = self.mapType(x,z)
local item = klass(pt, ...)
self:atPointPut(pt,item)
end
end
end
Unfortunately, self.mapType
is just a function that we call when we create the coordinates:
local function hex(x,z)
return MapPoint:hex(x,-x-z,z)
end
local function cartesian(x,z)
return MapPoint:cartesian(x,z)
end
However, we could certainly cause the MapPoint to know whether it is cartesian or hex. Let’s do that, and follow our nose. I expect it to be a bit messy, but once we have hung a rope across this chasm, we can build a better bridge.
function MapPoint:hex(x,y,z)
local pt = MapPoint(x,y,z, true)
if pt:invalid() then error("Invalid HexCoordinates "..x..","..y..","..z) end
return pt
end
function MapPoint:cartesian(x,z)
return MapPoint(x,0,z, true)
end
function MapPoint:init(x,y,z, secret)
self._x = x//1
self._y = y//1
self._z = z//1
self.keyString = string.format("%d,%d,%d", self._x, self._y, self._z)
assert(secret, "Invalid call to MapPoint("..self.keyString..")")
end
While I’m here, let’s fix something. I’ve learned that in Lua 5.3 and newer, if you have, say 1.5 and you do 1.5//1 you get 1.0, not the integer 1. Lua almost never requires you to know whether you have an integer in hand or not. It even does the right thing with tab[1] and tab[1.0]. But I really want these to be integers, just because. We use math.floor
for that.
function MapPoint:init(x,y,z, secret)
self._x = math.floor(x)
self._y = math.floor(y)
self._z = math.floor(z)
self.keyString = string.format("%d,%d,%d", self._x, self._y, self._z)
assert(secret, "Invalid call to MapPoint("..self.keyString..")")
end
Test, commit: use math.floor to force integer xyz in MapPoint. OK.
Now let’s add a flag in the MapPoint. For now, just a boolean.
function MapPoint:hex(x,y,z)
local pt = MapPoint(x,y,z, true, true)
if pt:invalid() then error("Invalid HexCoordinates "..x..","..y..","..z) end
return pt
end
function MapPoint:cartesian(x,z)
return MapPoint(x,0,z, false, true)
end
function MapPoint:init(x,y,z, isHex, secret)
self._x = math.floor(x)
self._y = math.floor(y)
self._z = math.floor(z)
self._isHex = isHex
self.keyString = string.format("%d,%d,%d", self._x, self._y, self._z)
assert(secret, "Invalid call to MapPoint("..self.keyString..")")
end
Nasty, but with luck we’ll have time to tidy it up. (Never try this at home: it rarely works.)
Now I think we have to look back at where Tile gets the graphic coordinates:
function Tile:graphicCenter()
return self:graphicCorner() + self:cornerToCenterOffset()
end
function Tile:graphicCorner()
return (self:pos() - vec2(1,1))*TileSize
end
I wonder whether we ever actually use graphicCorner
. It turns out that we do. I think I’d like things better if we only knew about the center in MapPoint. Let’s reverse the sense of these two methods.
function Tile:graphicCenter()
local corner = (self:pos() - vec2(1,1))*TileSize
return corner + self:cornerToCenterOffset()
end
function Tile:graphicCorner()
return self:graphicCenter() - self:cornerToCenterOffset()
end
Should be same-o same-o. Test. Game’s fine, two MapPoint tests break.
2: MapPoint Arithmetic -- MapPoint:22: Invalid call to MapPoint(1,0,-1)
3: Cartesian Point Arithmetic -- MapPoint:22: Invalid call to MapPoint(3,0,3)
Hm. Looks like my trivial creation change didn’t work. Oh. The bug is that I forgot to change my internal point creation on add.
function MapPoint:__add(aMapDirection)
if aMapDirection == nil then error("nil aMapPoint") end
if aMapDirection:is_a(MapDirection) then
-- subverts the true check.
return MapPoint(self:x()+aMapDirection.x, self:y()+aMapDirection.y, self:z()+aMapDirection.z, self._isHex, true)
else
error("Attempt to add a MapPoint plus a non-MapDirection")
end
end
I expect that to fix it. And it does. Commit: MapPoint knows isHex
. Tile graphicCorner and Center refactored.
Should have committed the Tile stuff already. My commit habits are not ideal.
OK, now I want to defer graphicCenter down to tile. To do that we’ll need to pass in the TileSize, I think.
Let’s review how we did the center in the D3 spike.
-- algorithm from Red Blob https://www.redblobgames.com/grids/hexagons/
-- adjusted for our coords
--local cos30 = 0.8660254038 = sqrt(3)/2
--local sin30 = 0.5
--local sqrt(3) = 1.7320508076
-- TODO this needs explanation and/or a diagram
function MapPoint:screenPos()
local rad = MapPoint:screenRadius()
local q = self:x()*rad
local r = self:z()*rad
local x = (1.7320508076*q + 0.8660254038*r) -- sqrt(3), sqrt(3)/2
local y = 1.5*r -- we invert z axis for z upward
return vec2(x,y)
end
function MapPoint:screenRadius()
return 32
end
The above is the hex calculation. That was all I was trying to learn in the spike. Let’s do a couple of tests for this.
Ah, excellent, there is one already:
_:test("Screen Positions", function()
_:expect(MapPoint:hex(0,0,0):screenPos()).is(vec2(0,0))
local oneUp = MapPoint:hex(1,-2,1):screenPos()
_:expect(oneUp.x).is(MapPoint:screenRadius()*1.5*math.sqrt(3), 0.01)
_:expect(oneUp.y).is(MapPoint:screenRadius()*1.5, 0.01)
end)
However, we don’t want MapPoint to know the screen radius any more. We want to pass that in: it’s a concern for Tile and other larger objects.
_:test("Screen Positions", function()
local screenRadius = 32
_:expect(MapPoint:hex(0,0,0):screenPos(screenRadius)).is(vec2(0,0))
local oneUp = MapPoint:hex(1,-2,1):screenPos(screenRadius)
_:expect(oneUp.x).is(screenRadius*1.5*math.sqrt(3), 0.01)
_:expect(oneUp.y).is(screenRadius*1.5, 0.01)
end)
Now to fix MapPoint:
function MapPoint:screenPos(screenRadius)
local q = self:x()*screenRadius
local r = self:z()*screenRadius
local x = (1.7320508076*q + 0.8660254038*r) -- sqrt(3), sqrt(3)/2
local y = 1.5*r -- we invert z axis for z upward
return vec2(x,y)
end
I expect the test to run so far. And it does. But we also need the equivalent for cartesian.
_:test("Cartesian Screen Positions", function()
local screenRadius = 25 -- tileSize is 50
local pt = MapPoint:cartesian(1,1)
_:expect(pt:screenPos(screenRadius)).is(vec2(0,0))
local upAndOver = MapPoint:cartesian(3,2)
_:expect(upAndOver:screenPos(screenRadius)).is(vec2(100, 50))
end)
Here I encounter two issues.
First, while it makes sense to think in terms of radius for hexes, our square tiles think in terms of their absolute size, 50, not radius of 25. I think we’ll want to change that so that we pass “diameter” to screenPos. We’ll deal with that in a moment.
Second, our square Tiles start at (1,1) and are supposed to map to 0,0. We saw that vec2(1,1) in there somewhere. We want to preserve that. Now, originally, I was creating the hex map as a ring around (0,0,0) but now it starts at (1,0,1), I think. We might want to make an equivalent change there. We’ll see.
For now, let’s make it work.
function MapPoint:screenPos(screenRadius)
if self._isHex then
return self:screenPosHex(screenRadius)
else
return self:screenPosCartesian(screenRadius)
end
end
function MapPoint:screenPosHex(screenRadius)
local q = self:x()*screenRadius
local r = self:z()*screenRadius
local x = (1.7320508076*q + 0.8660254038*r) -- sqrt(3), sqrt(3)/2
local y = 1.5*r -- we invert z axis for z upward
return vec2(x,y)
end
function MapPoint:screenPosCartesian(screenRadius)
local screenDiameter = 2*screenRadius
local pos = (vec2(self._x,self._z)-vec2(1,1))
return pos*screenDiameter
end
I think that’s right. Let’s see if the test agrees. It does.
Let’s convert to expect diameter in both.
I think this is the correct conversion of the tests:
_:test("Hex Screen Positions", function()
local screenDiameter = 64
_:expect(MapPoint:hex(0,0,0):screenPos(screenDiameter)).is(vec2(0,0))
local oneUp = MapPoint:hex(1,-2,1):screenPos(screenDiameter)
_:expect(oneUp.x).is(screenDiameter/2.0*1.5*math.sqrt(3), 0.01)
_:expect(oneUp.y).is(screenDiameter/2.0*1.5, 0.01)
end)
_:test("Cartesian Screen Positions", function()
local screenDiameter = 50
local pt = MapPoint:cartesian(1,1)
_:expect(pt:screenPos(screenDiameter)).is(vec2(0,0))
local upAndOver = MapPoint:cartesian(3,2)
_:expect(upAndOver:screenPos(screenDiameter)).is(vec2(100, 50))
end)
And the code:
function MapPoint:screenPosHex(screenDiameter)
local screenRadius = screenDiameter/2
local q = self:x()*screenRadius
local r = self:z()*screenRadius
local x = (1.7320508076*q + 0.8660254038*r) -- sqrt(3), sqrt(3)/2
local y = 1.5*r -- we invert z axis for z upward
return vec2(x,y)
end
function MapPoint:screenPosCartesian(screenDiameter)
local pos = (vec2(self._x,self._z)-vec2(1,1))
return pos*screenDiameter
end
I am hopeful that this runs. Change was tricky enough that I may have messed up.
Runs. Commit: MapPoint can compute hex and square screen position given tile diameter.
Time to lift our head up a bit, get some air.
Where Are We, Where Shall We Go?
OK, that went smoothly. We have five commits. We have the Points computing corresponding screen coordinates for us. I reckon we should be able to change Tile’s graphicCenter
method to defer to its MapPoint. Let’s try it.
Wait! Are those values in my cartesian test the ones that I want? I think they are not. Let’s look.
function Tile:graphicCenter()
local corner = (self:pos() - vec2(1,1))*TileSize
return corner + self:cornerToCenterOffset()
end
function Tile:graphicCorner()
return self:graphicCenter() - self:cornerToCenterOffset()
end
function Tile:cornerToCenterOffset()
return vec2(1,1)*(TileSize//2)
end
We forgot to factor that value into our test and code.
I think the test should be this:
_:test("Cartesian Screen Positions", function()
local screenDiameter = 50
local pt = MapPoint:cartesian(1,1)
_:expect(pt:screenPos(screenDiameter)).is(vec2(25,25))
local upAndOver = MapPoint:cartesian(3,2)
_:expect(upAndOver:screenPos(screenDiameter)).is(vec2(125, 75))
end)
I added 25,25 to all the answers, the corner to center offset. See test fail. Fix code:
function MapPoint:screenPosCartesian(screenDiameter)
local pos = (vec2(self._x,self._z)-vec2(1,1))
return pos*screenDiameter + vec2(screenDiameter/2, screenDiameter/2)
end
Now I think I can replace graphicCenter
…
function Tile:graphicCenter()
return self.mapPoint:screenPos(TileSize)
end
Tests run, game runs. Commit: Tile uses MapPoint to compute graphicCenter etc.
Now What?
What I’d like to do next, for values of next, is to hack in the creation of a hex map and try to get it to display. To do that, I guess I’d have to fudge Tile:draw
to check the hex flag and branch off to a different draw function. That is a hack but it is the hack I intended when I created the flag. Once we have the shape of the behavior in place, we can decide on a better way to do it, probably a double dispatch of some kind.
I fully expect the first experiment with this to explode ignominiously. Let’s bash something in.
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:drawSprites(tiny)
popStyle()
popMatrix()
end
Looks like a good place to bash.
Here’s the hex drawing from the D3 spike:
function Hex:draw()
pushMatrix()
pushStyle()
rectMode(CENTER)
strokeWidth(1.0/self:getScale())
local coord = self:screenPos()
local cx = coord.x
local cy = coord.y
translate(cx,cy)
for rot = 0,5 do
local bl = self:boundaryLine(rot)
line(table.unpack(bl))
end
self:drawFill(self.fillColor)
text(self:point():key(),0,0)
popStyle()
popMatrix()
end
That’s not quite what we want but let’s see if we can bash it.
I push in this much:
function Tile:drawHex(tiny)
pushMatrix()
pushStyle()
rectMode(CENTER)
strokeWidth(1.0/self:getScale())
local coord = self:screenPos(64)
local cx = coord.x
local cy = coord.y
translate(cx,cy)
for rot = 0,5 do
local bl = self:boundaryLine(32,rot)
line(table.unpack(bl))
end
popStyle()
popMatrix()
end
function Tile:boundaryLine(rad,angleIndex)
local bTop = rad*vec2(0.8660254038, 0.5) -- cos30 = 0.8660254038 = sqrt(3)/2, sin(30)
local bBot = rad*vec2(0.8660254038, -0.5)
local ang = angleIndex*math.pi/3.0
local vTop = bTop:rotate(ang)
local vBot = bBot:rotate(ang)
return {vTop.x, vTop.y, vBot.x, vBot.y}
end
function Hex:getScale()
return modelMatrix()[1]
end
Now how are we going to get a hex map created? Let’s use the learning mode button. That button winds up here:
function GameRunner:createLearningLevel()
self.dungeonLevel = 99
self:dcPrepare()
-- customize rooms and connections
self:createLearningRooms()
self:connectLearningRooms()
-- paint dungeon correctly
self.dungeon:convertEdgesToWalls()
-- ready for monsters
self.monsters = Monsters()
self:placePlayerInRoom1()
-- customize contents
self:placeWayDown() -- needs to be fixed room
--self:placeSpikes(5)
--self:placeLever()
--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
I’ll comment out everything but the minimum, and we’ll need to look at dcPrepare
, which I think creates the map.
I have hacked in a hex
flag, usually nil, sometimes true, that gets passed all the way down to here:
function Dungeon:createTiles(tileCountX, tileCountY, hex)
self.tileCountX = tileCountX
self.tileCountY = tileCountY
if hex then
self.map = Map:hexMap(self.tileCountX+1,self.tileCountY+1, Tile,TileEdge, self. runner)
else
self.map = Map:cartesianMap(self.tileCountX+1,self.tileCountY+1, Tile,TileEdge, self. runner)
end
self.tiles = {}
for x = 1,tileCountX+1 do
self.tiles[x] = {}
for y = 1,tileCountY+1 do
local tile = self.map:atXYZ(x,0,y)
self:defineTile(tile)
end
end
end
An issue, however, is that I’m still using that old self.tiles structure. A hexmap can’t be copied with that code, and I really don’t know what will happen with Tiles, which do assume square a lot. Let’s fix the copy. I think I can do that.
Wait. Remind me who looks at dungeon.tiles anyway. Essentially no one but tests. Let’s if that out in hex mode and see what happens.
Since I expect to revert, I’m just trying to get a sense of how much trouble we’re in, let’s run it and press the learning button.
Meh:
Dungeon:335: attempt to index a nil value (local 'tile1')
stack traceback:
Dungeon:335: in function <Dungeon:334>
(...tail calls...)
MonsterStrategy:42: in method 'execute'
Monster:324: in method 'executeMoveStrategy'
Monster:282: in method 'chooseMove'
Monsters:117: in method 'move'
GameRunner:497: in method 'playerTurnComplete'
Player:311: in method 'turnComplete'
Button:80: in method 'performCommand'
Button:64: in method 'touchEnded'
Main:118: in function 'touched'
Too messy. But why are there even any monsters to process? Don’t care comment out the call to chooseMove. Now
Player:173: attempt to index a nil value
stack traceback:
Player:173: in method 'graphicCorner'
GameRunner:532: in method 'scaleForPlayer'
OperatingModes:21: in method 'scaleForLocalMap'
GameRunner:519: in method 'scaleForLocalMap'
GameRunner:307: in method 'drawLargeMap'
GameRunner:286: in method 'draw'
Main:100: in function 'draw'
function Player:graphicCenter()
return self:getTile():graphicCenter()
end
She doesn’t have a tile. Hack that too.
After extensive hackery, I get this picture:
The error, not that it matters, is:
Player:169: attempt to index a nil value
stack traceback:
Player:169: in method 'graphicCenter'
Player:122: in method 'drawInLargeAndSmallScale'
OperatingModes:9: in method 'drawInLargeAndSmallScale'
Player:112: in method 'drawExplicit'
GameRunner:336: in method 'drawPlayerOnSmallMap'
GameRunner:291: in method 'draw'
Main:100: in function 'draw'
Player has no tile and thus cannot get her graphic center to display herself.
At a guess, that white area at the top left is the mini-map.
I did much bashing to get this far. Let me see if I can get some diff information out of WorkingCopy, to serve as notes for the real thing.
I can’t find a good way, but I’ve saved these photos:
Revert.
Perhaps I should have saved these changes on a branch or something, but that is not my way. My way is to learn from this experiment and then do something more sensible.
Let’s sum up.
Summary
We moved screen coordinate calculations down to the MapPoint, and included a hack-flag to allow us to decide whether a MapPoint is hex or square, which we can use to decide how to draw things.
Then we hacked in alternate drawing for hex tiles, and lost it in the revert. We likely could have saved that bit, but it is preserved for posterity here and in the pictures. Probably there will be a better idea tomorrow anyway.
We commented out huge tracts of code, defining rooms, converting edges to walls, placing the player, placing the way down.
We added a high level flag hex
that got passed down down down until finally the Map found out what kind of map to create. We had to mash the Player to deal with the fact that she didn’t have a Tile.
We created a new method drawHex on Tile, for it to use to draw, well, hexes.
We ran into trouble with the dual structure self.tiles
containing the alternate view of the map. I think that’s a big problem and we should probably get rid of it before plugging in the hex map. And there are some square assumptions lurking in there. Probably nothing difficult, but a lot of it.
Monsters were trying to move even though I thought there were none. No idea what that was about.
I was just following my nose on errors until the map displayed. The interesting array of errors that showed up is probably telling us something about the overall structure of the game. At the least, it’s saying that you can’t just go around turning things off at the source and have the game merrily carry on. I don’t think that’s concerning: there has been no need for the system to deal with such things, and while things may not be perfectly isolated, I didn’t see any spots where I wanted to scream.
I suspect that by the time we’ve removed the self.tiles
structure we’ll be in fairly good shape. We’ll need to use hex vs square directions, but the Map knows how to do that already.
Bottom line, after some hackery, the hex map appeared on the game screen. I’m not even worried about it being in the upper right, because we have its origin at 0,0 and growing up and right. When the Princess is somewhere, she’ll make the screen scroll appropriately.
So I’d say that the results were encouraging and far less nastiness was needed than there might have been. An interesting morning.
I hope to see both of you tomorrow.