Dungeon 234, Pumpkin Spice
No, not really. Cartesian coordinates, perhaps. Yesterday I was optimistic at session end. How will I feel today? Let’s find out.
It does still seem like a lot of work looms ahead if I’m going to get hex maps and square maps working in the Dung program, but I’m hopeful that it won’t be too much. And I’m sure that anything remotely difficult will teach a lesson about what it means to keep the code crispy and nice even during the hot deadline season.
Yesterday I tweaked the Hex code to do some renames and to create different classes for HexPoint and HexDirection, reflecting the difference between a point and a vector. I’m not sure this will pay off, but it’s trivially simple to do and should pay off in clarity if nothing else.
Today I think I’ll start by creating the corresponding classes for square tiles, which immediately faces us with a horrible problem.
The proper name for points and vectors on a set of right-angle axes is Cartesian. CartesianPoint and CartesianDirection. The number of typos in the previous two sentences tells me there’s an issue for me in those names. I could use SquarePoint and SquareVector, but that seems tacky now that I’ve remembered the correct names.
Of course, HexPoint and HexDirection aren’t particularly official, so perhaps I’m OK with simpler names.
Also, yesterday, I wanted to stay away from the term Vector, because there is a type in Coda Lua called vector
, and I’d really rather call my Directions Vector. But I guess I’d better not.
In for a penny. Here goes Cartesian. I’m going to do it in the D3 spike, where the Hex code lives, and then we’ll import the whole schmear into D2 and start working at HEAD.
Cartesian
I reckon I should build these babies with TDD, as I did the Hex ones.
The HexDirection class looks like this:
HexDirection = class()
function HexDirection:init(x,y,z)
self.x = x
self.y = y
self.z = z
end
There are no tests for it, though I think there probably is one for Point+Direction arithmetic. We’ll see.
CartesianDirection = class()
function CartesianDirection:init(x,y)
self.xx = x
self.yy = y
end
Why xx
and yy
? Because there are a lot of methods in Dung that are accessing the .x
and .y
member variables of other classes, and I want to get away from that.
I’m tempted to do something speculative here, which is to create a method asVec2
to convert an instance to a Lua vec2
, but let’s hold off until we need it. Not like it’s difficult.
OK, now for the point, I’ll look at the tests for hexes.
Hmm. They don’t actually test much. They check for validity, because HexPoints have a constraint on their coordinates. We don’t have that issue in Cartesian. (I need a macro for that word.) They test screen position, and that’s something I think we ought to change.
Let’s divert and do that now, because we’ll want to somewhat copy the idea for Cartesian.
In the Dung game today, the Tile indices are integers from 1 to the width and height of the game space. The screen positions of those Tiles are computed like this:
function Tile:graphicCenter()
return self:graphicCorner() + self:cornerToCenterOffset()
end
function Tile:graphicCorner()
return (self:pos() - vec2(1,1))*TileSize
end
In other words, Tiles are assumed not to be unit squares, but instead to be squares of size TileSize
, which is 64. But our Hexes are presently assumed to be of radius 1. That mostly works, but has caused us some problems with drawing rectangles that small and so on.
Let’s make our Hexes radius 32 and adjust all our drawing to deal with that. That normalization should improve the code a bit right now, and make doing the graphics in the real game a bit more similar between the two kinds of maps.
Here is HexPoint:screenPos
:
-- TODO this needs explanation and/or a diagram
function HexPoint:screenPos()
local q = self.x
local r = self.z
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 definitely agree with that comment. However, I think we can just accept that we don’t quite understand that code and adjust it to the new size. The size, I think, should be a method on HexPoint class, not just a literal. Let’s try this:
-- TODO this needs explanation and/or a diagram
function HexPoint:screenPos()
local rad = HexPoint: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 HexPoint:screenRadius()
return 32
end
That made so much sense to me. But here’s what comes out on the screen:
I expected a bunch of very large hexes, not just one that is just the same size as before. Most peculiar. Let’s work thru the failing tests.
2: Screen Positions -- Actual: 27.7128129216, Expected: 0.866
2: Screen Positions -- Actual: 48.0, Expected: 1.5
Those look to me to be the right answers. I’ll fix the test:
_:test("Screen Positions", function()
_:expect(HexPoint(0,0,0):screenPos()).is(vec2(0,0))
local oneUp = HexPoint(1,0,-1):screenPos()
_:expect(oneUp.x).is(HexPoint:screenRadius()*0.866, 0.01)
_:expect(oneUp.y).is(HexPoint:screenRadius()*1.5, 0.01)
end)
That should pass. And it does. So where are all my tiles, and why aren’t they a different size now? Well, one reason is that I didn’t change draw.
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
popStyle()
popMatrix()
self:drawFill(self.fillColor)
end
function Hex:boundaryLine(angleIndex)
local bTop = vec2(0.8660254038, 0.5) -- cos30 = 0.8660254038 = sqrt(3)/2, sin(30)
local bBot = 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:drawFill(aColor)
if aColor then
pushMatrix()
pushStyle()
stroke(aColor)
fill(aColor)
rectMode(CENTER)
self:drawUpscaledFilledRectangles()
popStyle()
popMatrix()
end
end
function Hex:drawUpscaledFilledRectangles()
-- upscale compensates for Codea's not handling tiny rectangles well.
local up = 10.0
local w = up*1.7320508076 -- sqrt(3)
local h = up*1.0
scale(1.0/up)
strokeWidth(1)
self:drawFlatRect(w,h)
self:drawLowToHighRect(w,h)
self:drawHighToLowRect(w,h)
end
Yes, well. I suspect that one hex on the screen is the outline of the whole picture or something. Let’s do some fixing here.
function Hex:boundaryLine(angleIndex)
local rad = HexPoint:screenRadius()
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
That will conveniently resize the lines. When I also change the Main:draw not to scale up to scale 50, we get this:
Much better. Now to fix the coloration. We should be able to skip over the fancy upscaling.
function Hex:drawUpscaledFilledRectangles()
local rad = HexPoint:screenRadius()
local w = rad*1.7320508076 -- sqrt(3)
local h = rad*1.0
strokeWidth(1)
self:drawFlatRect(w,h)
self:drawLowToHighRect(w,h)
self:drawHighToLowRect(w,h)
end
That gives us this:
Here’s a picture of Dung. Note its tile size compared to the above:
Now, we can scale as we wish to get the screen picture to be what we want, but the hex tiles are definitely smaller than the square ones. I guess we’ll leave that concern for later. I’ll rename that one method to remove the upScale remark:
function Hex:drawFill(aColor)
if aColor then
pushMatrix()
pushStyle()
stroke(aColor)
fill(aColor)
rectMode(CENTER)
self:drawFilledRectangles()
popStyle()
popMatrix()
end
end
function Hex:drawFilledRectangles()
local rad = HexPoint:screenRadius()
local w = rad*1.7320508076 -- sqrt(3)
local h = rad*1.0
strokeWidth(1)
self:drawFlatRect(w,h)
self:drawLowToHighRect(w,h)
self:drawHighToLowRect(w,h)
end
Excellent. Commit: Rescaled Hexes to radius 32.
Now, where were we? Oh yes, CartesianPoint. What can we test? Well, we’re going to need screenPoint, so we can test that. And we really should be testing the coordinate arithmetic on both classes.
I’ll test Hex first, to bring it up to snuff.
We do have this test:
--[[
east: (1,-1,0), northeast:(1,0,-1), northwest:(0,1,-1), west:(-1,1,0), southwest:(-1,0,1), southeast(0,-1,1)
--]]
_:test("Ring 1", function()
local directions = { HexPoint(1,-1,0), HexPoint(1,0,-1), HexPoint(0,1,-1),HexPoint(-1,1,0), HexPoint(-1,0,1), HexPoint(0,-1,1) }
local map = Hex:createMap(1)
for i,d in ipairs(directions) do
local h = map:atPoint(d)
local err = "at "..tostring(d).." found "..tostring(h)
_:expect(h,err).isnt(nil)
end
end)
That array shouldn’t be called directions
. It’s points, not directions. Rename to ringPoints
. OK, fine. Commit: improve test naming.
Let’s do some simple arithmetic and test it. This really seems silly to me, I know it works. How do I know? Well, I wrote it and I couldn’t have made a misteak.
_:test("HexPoint Arithmetic", function()
local point = HexPoint(0,0,0)
local notDir = HexPoint(1,0,-1)
local f = function() return point + notDir end
_:expect(f).throws("Attempt to add a HexPoint plus a non-HexDirection")
local dir = HexDirection(1,0,-1)
local result = point + dir
_:expect(result).is(HexPoint(1,0,-1))
local result2 = result + dir
_:expect(result2).is(HexPoint(2,0,-2))
end)
That’s not much, but is probably enough. Let’s do one for Cartesian.
That should drive out some code.
_:test("CartesianPoint Arithmetic", function()
local point = CartesianPoint(0,0)
local notDir = CartesianPoint(1,1)
local f = function() return point + notDir end
_:expect(f).throws("Attempt to add a CartesianPoint plus a non-CartesianDirection")
end)
That’ll get me started.
3: CartesianPoint Arithmetic -- Tests:35: attempt to call a nil value (global 'CartesianPoint')
OK, I did a bunch of stuff here:
CartesianDirection = class()
function CartesianDirection:init(x,y)
self.xDir = x
self.yDir = y
end
CartesianPoint = class()
function CartesianPoint:init(x,y)
self.xCoord = x
self.yCoord = y
end
function CartesianPoint:__tostring()
return "CartesianPoint("..self.xCoord..self.yCoord")"
end
function CartesianPoint:__add(aHexDirection)
if aHexDirection == nil then error("nil aHexPoint") end
if aHexDirection:is_a(CartesianDirection) then
return HexPoint(self.x+aCartesianDirection.xDir, self.y+aCartesianDirection.yDir)
else
error("Attempt to add a CartesianPoint plus a non-CartesianDirection")
end
end
function CartesianPoint:__eq(aCartesianPoint)
return aCartesianPoint:is_a(CartesianPoint) and self.xCoord == aCartesianPoint.xCoord and self.yCoord == aCartesianPoint.yCoord
end
I renamed the member variables on the Direction to xDir and yDir for clarity but still different from the old x and y in Tile.
I went beyond my test, so let’s get back there and beef it up.
Found typo:
function CartesianPoint:__add(aCartesianDirection)
if aHexDirection == nil then error("nil aHexPoint") end
if aHexDirection:is_a(CartesianDirection) then
return HexPoint(self.x+aCartesianDirection.xDir, self.y+aCartesianDirection.yDir)
else
error("Attempt to add a CartesianPoint plus a non-CartesianDirection")
end
end
I had failed to rename the parameter. The test is:
_:test("CartesianPoint Arithmetic", function()
local point = CartesianPoint(2,3)
local notDir = CartesianPoint(1,1)
local f = function() return point + notDir end
_:expect(f).throws("Attempt to add a CartesianPoint plus a non-CartesianDirection")
local dir = CartesianDirection(1,2)
print("dir ", dir)
local result = point + dir
_:expect(result).is(CartesianPoint(3,5))
end)
There is a lesson here. I wrote the methods in CartesianPoint by copy-paste from HexPoint, and hand-renaming the various occurrences of Hex to Cartesian. I messed that up profoundly, as you can see above. It has taken me at least ten minutes to notice those mistakes, because I was so certain that I had done nothing wrong, so I was looking for deep problems with the test and code, while the real problems were due to speculation, and to cutting and pasting instead of rewriting.
Bad Ron. But now we have this:
CartesianDirection = class()
function CartesianDirection:init(x,y)
self.xDir = x
self.yDir = y
end
function CartesianDirection:__tostring()
return "CartesianDirection("..self.xDir..","..self.yDir..")"
end
CartesianPoint = class()
function CartesianPoint:init(x,y)
self.xCoord = x
self.yCoord = y
end
function CartesianPoint:__tostring()
return "CartesianPoint("..tostring(self.xCoord)..tostring(self.yCoord)..")"
end
function CartesianPoint:__add(aCartesianDirection)
if aCartesianDirection == nil then error("nil aCartesianDirection") end
if aCartesianDirection:is_a(CartesianDirection) then
return CartesianPoint(self.xCoord+aCartesianDirection.xDir, self.yCoord+aCartesianDirection.yDir)
else
error("Attempt to add a CartesianPoint plus a non-CartesianDirection")
end
end
function CartesianPoint:__eq(aCartesianPoint)
return aCartesianPoint:is_a(CartesianPoint) and self.xCoord == aCartesianPoint.xCoord and self.yCoord == aCartesianPoint.yCoord
end
What Else?
HexPoint and HexDirection include a set of directions going around counter-clockwise from east:
function HexPoint:direction(n)
-- n = 1-6
return HexPoint:directions()[n]
end
function HexPoint:directions()
return{ HexDirection(1,-1,0), HexDirection(1,0,-1), HexDirection(0,1,-1),HexDirection(-1,1,0), HexDirection(-1,0,1), HexDirection(0,-1,1) }
end
Hm. That’s just weird. That code should have been moved to HexDirection. Let’s do it and fix what breaks.
function HexDirection:direction(n)
-- n = 1-6
return HexDirection:directions()[n]
end
function HexDirection:directions()
return{ HexDirection(1,-1,0), HexDirection(1,0,-1), HexDirection(0,1,-1),HexDirection(-1,1,0), HexDirection(-1,0,1), HexDirection(0,-1,1) }
end
What breaks:
7: Ring 1 -- HexPoint:36: attempt to call a nil value (method 'direction')
And the fix:
function HexPoint:createRing(radius, start)
local cell = start or HexPoint(0,0,0)
if radius == 0 then
return cell
end
cell = HexPoint:stepsFrom(cell,radius,HexDirection:direction(5))
local coords = {}
for _,direction in ipairs(HexDirection:directions()) do
for _ = 1,radius do
cell = self:oneStepFrom(cell,direction)
table.insert(coords, cell)
end
end
return coords
end
Past time for a commit. Commit: Some rudimentary tests for CartesianPoint and Direction. Some refactoring.
Are We There Yet?
We’ve resized the Hexes, making their drawing a bit more sensible, and we’ve implemented corresponding classes CartesianPoint and CartesianDirection like HexPoint and HexDirection.
Are we ready to pull this stuff into D2 and begin working there? The next steps over there will be to push the coordinate stuff downward under Tile, and to begin to try to make Dungeon agnostic about what kind of coordinate space is underneath it.
Ah, for that to happen, I think we need a corresponding class to HexMap:
HexMap = class()
function HexMap:init()
self.tab = {}
end
function HexMap:add(aHex)
self:atKeyPut(aHex:key(), aHex)
end
function HexMap:atXYZ(x,y,z)
return self:atPoint(HexPoint(x,y,z))
end
function HexMap:atPoint(hexPoint)
return self:atKey(hexPoint.key)
end
function HexMap:atQR(q,r)
return self:atPoint(HexPoint(q, -q-r, r))
end
function HexMap:atKey(aKey)
return self.tab[aKey]
end
function HexMap:atKeyPut(aKey, aHex)
self.tab[aKey] = aHex
end
function HexMap:draw()
for k,hex in pairs(self.tab) do
hex:draw()
end
end
So much of that is speculative. And where is the map even created? That’s done up in Hex:
function Hex:createMap(radius)
local map = HexMap()
for r = 0, radius do
Hex:createRing(r, map)
end
return map
end
Ah, that reminds me. I was thinking that we might want a nearly-rectangular map of Hexes, rather than a ring-shaped one. No real reason to think that, though, just speculating. But I do think we want a cartesian map ability, and we might as well write it here.
But we don’t even have the equivalent of a Hex or HexMap yet. We need them. The map equivalent can be CartesianMap, I guess. I’m starting to hate that word. But what is the element of a cartesian map? I guess it’ll be Square, until I get a better idea.
One better idea might be that a Map has elements and can either create an element or return the type to be created. We’ll try to keep that in mind.
For now … a Hex can draw itself and not much else. Our Squares or whatever, will have a much more robust draw method, since they have tiles and such. That’ll be for later.
I think all this is too speculative. It’ll be harder to do inside the D2 program, but it’ll be more focused on what we really need. So I’ll do the minimum here and get ready to move over.
CartesianMap
We know that the CartesianMap will be indexed by integer coordinates x and y, from 1 to some limit. (We may encounter some dissonance compared to the Hexes, which are zero-centered.) In the existing code, the Tiles are kept in a table of rows, or maybe columns, whichever it is, and are accessed by tiles[x][y]
. Hexes have three coordinates presently, and are accessed by creating a string “x,y,z” and using that as a key.
Would we be better off using the somewhat more natural column/row storage for CartesianMap, or following the style of creating a unique key and using that? Let’s stick with natural. Presumably no one can tell the difference.
Begin with a test.
_:test("Cartesian Map", function()
local map = CartesianMap(10,15)
for x = 1,10 do
for y = 1,15 do
local sq = map:atXY(x,y)
local c = sq:coord()
_:expect(c.xCoord).is(x)
_:expect(c.yCoord).is(y)
end
end
end)
8: Cartesian Map -- Tests:97: attempt to call a nil value (global 'CartesianMap')
CartesianMap = class()
function CartesianMap:init(xWidth, yHeight)
self.xWidth = xWidth
self.yHeight = yHeight
self.columns = {}
for x = 1,self.xWidth do
self.columns[x] = {}
for y = 1, self.yHeight do
self.columns[x][y] = Square(x,y)
end
end
end
function CartesianMap:atXY(x,y)
return self.columns[x][y]
end
function CartesianMap:draw()
end
Square = class()
function Square:init(x,y)
self.coord = CartesianPoint(x,y)
end
function Square:coords()
return self.coord
end
What’s not to like? Well, HexMap has add
and we don’t need that here. Maybe when we’re done, HexMap will be created in place, like CartesianMap is.
Anyway the test runs. Commit: Added CartesianMap and Square classes.
Summary
It’s 1130 local, and its Saturday, so let’s sum up.
We now have reasonably parallel classes ready for use in the D2 program’s conversion to allow both hex and rectangular maps:
Hex | Square |
HexMap | CartesianMap |
HexPoint | CartesianPoint |
HexDirection | CartesianDirection |
We have a few not unreasonable tests for the classes, and they can now identify their screen positions in similar but not identical ways. The Hex can draw itself. The Square cannot, but we anticipate having to push a lot of Tile code down into Square, and when we get around to making the Hexes look like anything, we’ll have analogous code down there.
At this writing, I think we’re ready to move these classes and tests over to D2 and begin converting. What have we learned?
What we’ve done here, as I look back upon it, is to come up with two platforms, down at the bottom of the world, one for supporting hexagonal maps and one for square-tiled maps. This is the sort of thing you do when changing the basics of your platform. What has happened is that the details of our platform design, square tiles, is a bit too tightly integrated with the details of the game design.
Is it too tight now, with only square tiles? Well, possibly, but I think it’s not bad, just not ideal. We could certainly have abstracted out the map a bit better than when we moved most of it down to Dungeon class, and we could certainly push some of the geometry down further. But it doesn’t seem to me to be something you’d look at and think “oh, this is wrong”.
That said, I’ve periodically complained about the complexity of some of the classes, and in fact Dungeon class was created to reduce complexity in GameRunner. So, is there a lesson here?
I’m not sure. I think maybe there are a few candidates:
- Avoid use of primitives, like numbers and even vectors. Always provide meaningful classes, considering dimensions carefully.
- Avoid use of raw collections. Always create meaningful collections (like HexMap) with methods custom made for manipulating those collections.
- Pay attention to classes that begin to take on more than one responsibility. Tile, for example, has drawing responsibility, content-management responsibility, entry management, illumination, neighbor information … probably more. Some of those things may belong in a single class. Others clearly do not belong together.
So the larger lesson is that the way I work, classes tend to stick together longer than may be ideal. Separating out different concerns sooner might make things better.
There are countervailing forces however. One of the main ones is that Codea is an iPad program, and it is not really well-suited for programs consisting of a large number of classes. There’s no hierarchical display of classes or much of anything. Just tabs, lots and lots of tabs, of which you can only see a few. This characteristic discourages splitting things up.
That’s no excuse, it’s just a force that acts on me. But balancing countervailing forces often requires a compromise. Big classes slow me down because they are harder to understand and harder to find things in. Breaking classes up slows me down because the tabs multiply and slow down editing across classes.
This is the same in any language, any IDE. The specific balance points are dependent on language, IDE, and the individuals doing the work.
Bottom line, though, I think we’re getting a sign that D2 is certainly not well-factored enough to easily accept hex maps, and is perhaps not well-factored enough for ideal development even in square maps. Our work of the past few days is aimed at improving that situation.
I’m a bit irritated by what I’ve done here. I feel that it would be “better” to do this work right inside D2, refactoring smoothly until we had these classes all nice and perky. I feel it is a bit of a “failure” that I had to do two or three whole days of work off in a separate space.
I am a fool to feel that way. First of all, it makes perfect sense to get a feel for an ideal way to do something, and it’s very difficult to find “ideal” in the middle of all the classes and methods of a maturing application. So this is quite likely a good thing to have done, perhaps the best thing.
Second, I chose this path on purpose, and so far it’s working nicely. There’s nothing of “failure” about it, other than some ghostly feeling that if I were more godlike I could have somehow just typed hexes into D2.
I don’t think so. I think D2 is not ideal, but it’s not bad. And I still think we’ll find that the Hexes go in rather nicely.
If not, well, it won’t be the first time I was wrong …
See you next time!