Dungeon 226
Let’s push this Hex spike a bit further, see what we can learn. Come on, it’ll be fun. You can hold my beer. Well, chai.
Yesterday I TDD’d some objects supporting hexagonal tiles, to experiment with an idea from Tuesday’s meeting of the Friday Night Coding Ensemble. I think that went well, including even some TDD on graphical positioning. And we got a few interesting objects right off the bat:
- Coord
- This is a class containing a x and y coordinate pair, amounting to column and row. Coord knows how to compare for equality, used in testing, and knows how to return a screen position given its x and y. That position isn’t quite correct, it just uses values that I estimated from a sketch. Yes, I do know how to use geometry to get the real values. I intend to prove it today.
- HexArray
- This is a class containing a specified number of Hex objects, assumed to be positioned in x and y. HexArray can give us the Hex at coordinates x, y, on request.
- Hex
- This class represents a game tile in hexagonal form. It has no contents other than a Coord, which it can return. It answers to
screenPos
, which it forwards to Coord.
In the Main part of the program, I’ve created a HexArray and drawn it, with explicit code in the draw to draw the hexagon that the Hex represents. Like this:
pushMatrix()
pushStyle()
ellipseMode(CENTER)
rectMode(CENTER)
stroke(color(255))
strokeWidth(1.0/50.0)
noFill()
translate(500,100)
scale(50)
for x = 1,10 do
for y = 1,15 do
local hex = Hexes:get(x,y)
local coord = hex:screenPos()
local cx = coord.x
local cy = coord.y
local prev = vec2(0, 0.5)
for rot = 1,6 do
local next = prev:rotate(math.rad(60))
line(cx+prev.x, cy+prev.y, cx+next.x, cy+next.y)
prev = next
end
end
end
popStyle()
popMatrix()
The picture we get reflects the fact that the screenPos
calculations are approximate:
There’s nothing I can do in the drawing to fix the spacing, that’s baked into the objects. So our first mission this morning is to compute better coordinates.
But there is another matter that I’m trying to keep in mind. On our Friday Night Coding channel, while thinking about that ragged array you get when you tile with hexagons, I mused
Hmm, how the hell do you index hexes?
Bill Wake responded:
0 through 5 – six slices of 60-degree pizza.
(I hope I’m not violating the privacy policy of the slack here.)
That notion is more interesting the more I think about it. Maybe a landscape of hexagons isn’t arranged in rows and columns at all. Maybe it grows from center outward, like a sunflower. I suppose it could also grow from some corner, given constraints on the boundaries. It’s a very interesting notion, and for all I know, it’s the canonical way to handle hex maps. I know nothing. I’m not going to look anything up, I’m just going to run with the idea at some point.
But First, the Geometry
First, though, we need to get these hex babies lined up right. I gotta draw a picture to think about this.
The horizontal spacing between hexes is 2x, where x is the distance from the center to the vertical side. That value is the x coordinate of the corner 30 degrees above the x axis.
I’ve been supposing that these hexes have radius 0.5, but I think the world will be a better place if we give them radius 1. We’re going to scale everything to draw it anyway, and radius 1 just makes more sense.
I don’t think I really want to TDD this value, because I am quite certain that I can do the necessary rotations to get it, but in a spirit of fairness, I’ll do it. We have a test that needs revision anyway.
But no. I’m going to let the existing tests break and fix them. Let’s write a new test to get the magical x value.
_:test("X spacing", function()
local expectedWidth = 0
local hex = Hex(0,0)
local hexX = hex:xWidth()
_:assert(hexX).is(expectedWidth)
end)
Of course zero isn’t the expected width. What is that width? Well, we have an angle of 30 degrees and we want the x projection of the point of radius one at that angle. The cosine of the angle is x/1, or x. So what is the cosine of 30 degrees? I could ask Siri, but she’s not very accurate. I’ll ask PCalc, a marvelous calculator app for the iPad.
PCalc says 0.8660254038. Close enough.
Wait. Why am I testing this? It’s a bloody constant.
… just a few minutes later …
Arrgh. I’ve lost the thread. Made a change, made another, now suddenly I’ve messed up the screenPos
function and since the test doesn’t have the right answers either. I’m in trouble.
Worse yet, this little program isn’t under version control. I’d best fix that first. Exit, put it into Working Copy, my iPad Git client. Excellent program, by the way.
OK. That’s done. “Initial commit, somewhat broken”. Bad Ron, no biscuit again today.
Now let’s figure out what the test should say and work from there.
Since I’m rethinking, I think I’ll change some rules. I’ll stick, for now at least, with the notion of indexing the tiles in simple x and y coordinates, 1 to xWidth, 1 to xHeight. But let’s make the bottom row be the one that extends to the left. In the present version it’s the second row, just because I thought that looked better. So the new rule is that Hex(1,1) is at graphical coordinates 0,0, and, let’s see, Hex(2,1) will be at 2*cos(30, 0), and so on.
What about the y change? It turns out that sin(30) is 0.5, which means that the delta y is 1.5, as we can see here:
So the new test is:
_:test("Graphical Coords", function()
local cos30 = 0.8660254038
local sin30 = 0.5
local hexes = createHexes(15,10)
local h11 = hexes:get(1,1)
local h21 = hexes:get(2,1)
local h12 = hexes:get(1,2)
local h22 = hexes:get(2,2)
local h11pos = h11:screenPos()
local h21pos = h21:screenPos()
local h12pos = h12:screenPos()
local h22pos = h22:screenPos()
_:expect(h11pos.x).is(0.0)
_:expect(h21pos.x).is(2*cos30)
_:expect(h11pos.y).is(0)
_:expect(h21pos.y).is(0)
_:expect(h12pos).is(vec2(cos30,1.5), 0.01)
_:expect(h22pos).is(vec2(3*cos30, 1.5), 0.01)
end)
I’m fairly confident in those values. I see a pattern in the h21-h22 there, but I’m not sure I should exploit it because I’m not 100 percent confident.
Let’s recode the screenPos
function from scratch. I’ll start with x:
local cos30 = 0.8660254038
local sin30 = 0.5
function Coord:screenPos()
local xSpacing = 2*cos30
local x = (self.x -1)*xSpacing
local y = 0
return vec2(x,y)
end
That should make a few of the checks pass.
Silly me, CodeaUnit isn’t smart enough to handle epsilon on a vector compare. Recode test:
_:test("Graphical Coords", function()
local cos30 = 0.8660254038
local sin30 = 0.5
local hexes = createHexes(15,10)
local h11 = hexes:get(1,1)
local h21 = hexes:get(2,1)
local h12 = hexes:get(1,2)
local h22 = hexes:get(2,2)
local h11pos = h11:screenPos()
local h21pos = h21:screenPos()
local h12pos = h12:screenPos()
local h22pos = h22:screenPos()
_:expect(h11pos.x, "h11pos.x").is(0.0)
_:expect(h21pos.x, "h21pos.x").is(2*cos30)
_:expect(h11pos.y, "h11pos.y").is(0)
_:expect(h21pos.y, "h21pos.y").is(0)
_:expect(h12pos.x, "h12pos.x").is(cos30, 0.01)
_:expect(h12pos.y, "h12pos.y").is(1.5)
_:expect(h22pos.x, "h22pos.x").is(3*cos30, 0.01)
_:expect(h22pos.y, "h22pos.y").is(1.5)
end)
I decided to annotate all the expectations, for quicker understanding of the failures. Remind me to talk about the “one assertion per test” trope. The failures are:
3: Graphical Coords h12pos.x -- Actual: 0.0, Expected: 0.8660254038
3: Graphical Coords h12pos.y -- Actual: 0.0, Expected: 1.5
3: Graphical Coords h22pos.x -- Actual: 1.7320508076, Expected: 2.5980762114
3: Graphical Coords h22pos.y -- Actual: 0.0, Expected: 1.5
None of these are terribly unexpected, since our current code doesn’t address any of the real issues. We have:
local cos30 = 0.8660254038
local sin30 = 0.5
function Coord:screenPos()
local xSpacing = 2*cos30
local x = (self.x -1)*xSpacing
local y = 0
return vec2(x,y)
end
We need the x adjustment for alternating rows, and it’d be nice to do something about y.
First this:
function Coord:screenPos()
local xSpacing = 2*cos30
local x = (self.x -1)*xSpacing
if y %2 == 0 then
x = x + cos30
end
local y = 0
return vec2(x,y)
end
I expect this to fix some tests. But not just looking at y that way.
if self.y%2 == 0 then
x = x + cos30
end
Now then. Everything is good except for the y values, which we haven’t yet computed.
local cos30 = 0.8660254038
local sin30 = 0.5
function Coord:screenPos()
local xSpacing = 2*cos30
local ySpacing = 3*sin30
local x = (self.x -1)*xSpacing
if self.y%2 == 0 then
x = x + cos30
end
local y = (self.y-1)*ySpacing
return vec2(x,y)
end
And the tests run. As a bonus, the screen looks like this:
That’s to be expected, since we’re still drawing with a radius of 0.5. Now permit me to inline the constants.
--local cos30 = 0.8660254038
--local sin30 = 0.5
function Coord:screenPos()
local xSpacing = 1.7320508076 -- 2*cos30
local ySpacing = 1.5 -- 3*sin30
local x = (self.x -1)*xSpacing
if self.y%2 == 0 then
x = x + 0.8660254038 -- cos30
end
local y = (self.y-1)*ySpacing
return vec2(x,y)
end
Tests are green.
One Assertion per Test?
A common TDD rule of thumb is to have just one assertion per test. This, combined with good test naming, provides a quick understanding of what has broken in the test. The test name says exactly what broke.
You’ll notice that I don’t follow that rule of thumb. The reason is that the test’s setup would be longer than the assertions by far. I prefer to do as I do, or I wouldn’t do it. YMMV, and I do recommend trying the one assertion rule for a while. It will likely provide you with tests that are better than mine.
The Graphics
Now let’s fix the graphics and see how it looks. Along we’ll see about pushing a bit more of the graphics code over into the objects, where it should be more generally useful.
Currently we have this as the loop that draws all the hexes.
for x = 1,10 do
for y = 1,15 do
local hex = Hexes:get(x,y)
local coord = hex:screenPos()
local cx = coord.x
local cy = coord.y
local prev = vec2(0, 0.5)
for rot = 1,6 do
local next = prev:rotate(math.rad(60))
line(cx+prev.x, cy+prev.y, cx+next.x, cy+next.y)
prev = next
end
end
end
The first change, of course, is to change that vector to (0,1), which should make the hexes fill the space:
I love it when a plan comes together. This also give us just a bit more of a jolt of confidence, since it looks so nice and never did before.
Now that loop is kind of weird, in the way it handles next and prev, munching forward rotating the line.
Remember that I started this spike with the idea of a Boundary object, where a Tile would have a Boundary on each of its sides. Let’s see whether we can make that happen.
Let’s simplify the graphical code a bit, because I think it’ll help me explain my thinking.
I say “simplify”. What I plan to do is add another translation, to position our hex at the center of the current drawing position:
for x = 1,10 do
for y = 1,15 do
pushMatrix()
local hex = Hexes:get(x,y)
local coord = hex:screenPos()
local cx = coord.x
local cy = coord.y
translate(cx,cy)
local prev = vec2(0, 1)
for rot = 1,6 do
local next = prev:rotate(math.rad(60))
line(prev.x, prev.y, next.x, next.y)
prev = next
end
popMatrix()
end
end
We save and restore the matrix, and then translate to the center of our current hex. That means that our line
calls need not include cx
and cy
.
Now, I have in mind that we should be able to ask a Hex for its boundary number N, for N from zero to five. We’ll have zero be the boundary in the direction of angle zero, i.e. positive x, and they’ll go counter-clockwise, in order of increasing conventional angle.
And we’ll teach the Boundary to draw itself.
Let’s TDD, though I am tempted to just do this in the graphics.
Had to modify the test to understand table unpacking:
_:test("Boundaries", function()
local cos30 = 0.8660254038
local sin30 = 0.5
local hex = Hex(0,0)
local b0 = Hex:boundaryLine(0) -- expect { X1, Y1, X2, Y2 } endpoints
local x1,y1,x2,y2 = table.unpack(b0)
_:expect(x1).is(cos30, 0.01)
_:expect(y1).is(0.5)
end)
Then I just went crazy and coded this, which I think might actually be right. We’re told that in TDD you never write a line of code that isn’t required by the test, but in real life, well, I sometimes do. This is one of those times:
function Hex:boundaryLine(angleIndex)
local bTop = vec2(0.8660254038, 0.5)
local bBot = vec2(0.8660254038, -0.5)
local angs = {0,60,120,180,240,300}
local ang = math.rad(angs[1+angleIndex%6])
local vTop = bTop:rotate(ang)
local vBot = bBot:rotate(ang)
return {vTop.x, vTop.y, vBot.x, vBot.y}
end
That darn 1 is in there because of Lua indexing. Could fiddle the table, I suppose, but I didn’t.
The test runs. Let’s add another checked angle. I’ll do two tests this time, including extending and renaming the first one:
_:test("Boundary zero", function()
local cos30 = 0.8660254038
local sin30 = 0.5
local hex = Hex(0,0)
local b0 = Hex:boundaryLine(0) -- expect { X1, Y1, X2, Y2 } endpoints
local x1,y1,x2,y2 = table.unpack(b0)
_:expect(x1).is(cos30, 0.01)
_:expect(y1).is(0.5)
_:expect(x2).is(cos30, 0.01)
_:expect(y2).is(-0.5)
end)
_:test("Boundary one", function()
local cos30 = 0.8660254038
local sin30 = 0.5
local hex = Hex(0,0)
local b0 = Hex:boundaryLine(1) -- expect { X1, Y1, X2, Y2 } endpoints
local x1,y1,x2,y2 = table.unpack(b0)
_:expect(x1).is(0, 0.01)
_:expect(y1).is(1.0, 0.01)
_:expect(x2).is(cos30, 0.01)
_:expect(y2).is(0.5, 0.01)
end)
This runs. I’m calling it done. I see no point to testing the other four. They can only be wrong if I typed in the wrong angle. That’s my story and I’m sticking to it.
Using Boundary
Now let’s use the new boundaryLine
function in the graphical bits:
for x = 1,10 do
for y = 1,15 do
pushMatrix()
local hex = Hexes:get(x,y)
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
end
Picture’s the same. Well past time to commit. “Hexes radius 1. Boundary line calcs.” Whew, that feels better. I really should have committed right off the bat. And of course, put it into Working Copy first thing. But it’s “just a spike”. Yeah, it’s more than that, it’s a multi-day experiment. Deserves decent treatment.
It’s easy to be sorry that something isn’t in version control.
It’s easy to be sorry that something isn’t in version control. It’s difficult to be sorry that it is. I need better habits, but these are the habits I’ve got. I try to lean in the direction of better.
I’m tempted to create a Boundary object, but at this moment I don’t know what it would do. It’s 1030, so let’s sum up and get outa here.
Summary
The point of this morning was to fix the approximate geometry of yesterday’s Hexes, while keeping the notion of Boundary (and pizza) in mind.
I had a mental stumble, probably distracted by the cat or something, and lost the thread. And it was the worst possible time, because my tests weren’t adequate and I didn’t even have a version in Working Copy to revert to.
The good news is that, after putting the mess under version control, I got better tests in place, and thereafter things went smoothly.
And we’re at a fairly nice place now. Our hexes can’t quite draw themselves (and I’m not sure they should), but they know how to return all the necessary information to allow them to be drawn.
Along the way I changed them to be of radius 1 instead of diameter 1. Just made more sense to me. Of course that means scaling will be halved, but we don’t really need scaling yet.
So a bit more capability, the boundary logic, and a bit better testing and a bit nicer code. A decent couple of hours.
See you next time!