Dungeon 229--The Realization
I have to face facts: I simply enjoy writing code and making it better.
There you have it. Now, I believe that improving code is the same over a very wide range of languages and applications. My personal evidence is that I have worked to improve code over a very wide range of languages and applications, and my thoughts and discoveries are always much the same. That, and the fact that I enjoy doing this stuff, is enough of a rationale to keep doing it.
For a while yet, I’m going to do it with these Hex things.
I have a few things in mind for this and future articles:
- Hexes can draw themselves;
- Separation of drawing from being;
- Rings and discs;
- Colored lines and fills;
- Variable line width around the hex;
- Lines, area fills, and paths.
We’ll see which of these happens. Something more interesting might turn up. I might even discover that I have something better to do with my time. Today, I want to finish up the ring code, and use that to build disc code, which will allow me to build a big hex of hexes.
And I’m sure we have some cruft to tidy up. Let’s get started.
Quick Review
I like to start every session with a quick review of where we are, with focus on the tests and code. Since my practice when starting out is to build the new classes and methods right in with the tests, I think part of this morning’s action will be to separate out the classes into their own tabs.
From Memory
Before I let the code distract me, I want to remember these things:
- Weird code looking up 0, 60, 120
- In the code that draws the lines around a hex, there’s a place where I look up the angles from a trivial table. Better to calculate them?
- Drawing is done ad hoc in Main
- Drawing an object should be closely associated with the object. However, I am uncomfortable with “the object draws itself”, which I too often turn into having drawing logic right in the thing. It should probably be separate but associated.
- Coords should be more invisible
- We can probably arrange things so that we have the notion of directions, and of moving from a given hex in a given direction, but with the actual coordinate objects and calculation being well hidden inside the Hex somewhere.
We’ll look for all these things and more, as we review the code.
Moving the Code
Let’s begin our review by moving the classes we have into separate tabs. My practice as a program grows is to keep each class in its own tab, and the tests in one or more separate tabs. My vague notion is that if anyone ever wanted the code, they could build a production version without the tests. I have no expectation that that will ever happen.
Coord
-- Coord
-- RJ 20210926
Coord = class()
function Coord:init(x,y,z)
self.x = x
self.y = y
self.z = z
if self:invalid() then error("Invalid Coordinates "..x..","..y..","..z) end
self.key = x..","..y..","..z
end
function Coord:__tostring()
return "Coord("..self.key..")"
end
function Coord:__add(aCoord)
if aCoord == nil then error("nil aCoord") end
if aCoord:is_a(Coord) then
return Coord(self.x+aCoord.x, self.y+aCoord.y, self.z+aCoord.z)
else
error("Attempt to add a Coord and something else")
end
end
function Coord:__eq(aCoord)
return aCoord:is_a(Coord) and self.x == aCoord.x and self.y == aCoord.y and self.z == aCoord.z
end
function Coord:invalid()
return 0 ~= self.x + self.y + self.z
end
function Coord:valid()
return not self:invalid()
end
-- 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
function Coord: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
This is an interesting class, in that it embeds a lot of deep boilerplate, with its __add
, __eq
, as well as the more common __tostring
.
At a quick glance, I think this is mostly OK, except that the screenPos
method is quite obscure. No one is likely to look at that and understand it. It needs a diagram or something. For now, I’m going to put a TODO on it just to remind me. As you know, that does not imply that I’ll ever do anything. But I might.
Moving right along …
Hex
Hex = class()
function Hex:init(x,y,z)
self.coord = Coord(x,y,z)
end
function Hex:__tostring()
return "Hex("..tostring(self.coord)..")"
end
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
function Hex:coords()
return self.coord
end
function Hex:key()
return self.coord.key
end
function Hex:screenPos()
return self.coord:screenPos()
end
There’s my weird table of angles, up there in boundaryLine
. We can improve that in one of two ways. We can certainly compute the angles as angleIndex*60
. Or we could make the table be radians. Or, I suppose, we could compute the angle as angleIndex*radians60
and save the call to math.rad.
I think this method is in the wrong place but may be the best place we have right now.
Let’s see, what’s 60 degrees in radians? 2pi/6, pi/3.
Let’s put that in right now.
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
Tests run. That reminds me, I should be committing each of these tab changes after the tests run. Done.
Comb
Comb = class()
function Comb:init()
self.tab = {}
end
function Comb:add(aHex)
self:atKeyPut(aHex:key(), aHex)
end
function Comb:atXYZ(x,y,z)
return self:atCoord(Coord(x,y,z))
end
function Comb:atCoord(aCoord)
return self:atKey(aCoord.key)
end
function Comb:atQR(q,r)
return self:atCoord(Coord(q, -q-r, r))
end
function Comb:atKey(aKey)
return self.tab[aKey]
end
function Comb:atKeyPut(aKey, aHex)
self.tab[aKey] = aHex
end
I hate that name. It was supposed to connote Honeycomb, because hexagons, and it doesn’t even do that very well.
Let’s change it right now before it’s too late. (It’s never too late but it gets to be more of a pain.
Let’s rename it to HexMap. Done. I pasted the code over to Sublime on my new MacBook Air and used its multi-cursor stuff to rename. Tests run. Commit: Rename Comb to HexMap, put in own tab.
Reviewing HexMap, what we see is all those speculative methods, atXYZ
, atCoord
, atQR
, and so on. I think some of them are never used. The class is trivial, but surely a lot of it is wasted. I’m sore tempted to remove the unused untested methods. If Rich Garzaniti had access to my computer, I know he’d do it.
What’s Left in the Test Tab?
Other than tests, we just have this utility function:
function HexDisc(ignoredCount)
local map = HexMap()
map:add(Hex(0,0,0))
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
local prev = Coord(0,0,0) + directions[5]
for i = 1,6 do
local next = prev + directions[i]
local hex = Hex(next.x, next.y, next.z)
map:add(hex)
prev = next
end
return map
end
That was really just an experiment to draw our current big picture:
We do want to be able to do that. Should it be a method of HexMap, or perhaps a class method on Hex? I think it creates Hexes and should therefore belong on Hex as a class method.
And I’d like for it to work by the Disc method repeatedly calling a Ring method, so that we’ll have Rings as well as Discs. And it should be able to draw a ring around any hex …
Ah. That suggests that ring
and disc
can be methods on Hex. But should they create the Hexes into the map, or just return them for someone else to create? Let’s think about that. We might want to inspect the Hexes in a ring around our avatar. And we might want to create hexes. The latter is probably less common.
This makes me think that ring and disc should be methods on Coord, and return a table of coords to be used to fetch or store hexes as one may wish.
But I do want to keep the Coord notion rather private, inside the Hex idea. Let’s start with a class method on Hex to create a disc, and work the code until we like it.
Creating Rings and Discs
I start with this renamed and moved method:
function Hex:createMap(radius)
local map = HexMap()
map:add(Hex(0,0,0))
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
local prev = Coord(0,0,0) + directions[5]
for i = 1,6 do
local next = prev + directions[i]
local hex = Hex(next.x, next.y, next.z)
map:add(hex)
prev = next
end
return map
end
Renaming throughout keeps the tests running. Commit: moved HexDisc function to Hex:createMap.
Now this function is working by drawing rings. It only knows how to draw a ring of radius one (and the trivial one or radius zero, which is done explicitly. Let’s extract a ring method and call it. We’ll want to pass it the map.
function Hex:createMap(radius)
local map = HexMap()
for r = 0, radius do
Hex:createRing(r, map)
end
return map
end
function Hex:createRing(radius, map)
if radius == 0 then
map:add(Hex(0,0,0))
else
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
local prev = Coord(0,0,0) + directions[5]
for i = 1,6 do
local next = prev + directions[i]
local hex = Hex(next.x, next.y, next.z)
map:add(hex)
prev = next
end
end
end
This could be nicer. (Understatement for effect.) (Litotes?)
This guy is working with Coords. He should be asking Coord to do this stuff for him.
function Hex:createRing(radius, map)
if radius == 0 then
map:add(Hex(0,0,0))
else
local coords = Coord:createRing(radius)
for i,c in ipairs(coords) do
map:add(Hex(c.x, c.y, c.z))
end
end
end
function Coord:createRing(radius, start)
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
local prev = (start or Coord(0,0,0)) + directions[5]
local coords = {}
for i = 1,6 do
local next = prev + directions[i]
table.insert(coords, next)
prev = next
end
return coords
end
Now Coord creates a ring of coords and we create Hexes in Hex. That’s close to reasonable.
Coord should handle the radius zero case for us:
function Hex:createRing(radius, map)
local coords = Coord:createRing(radius)
for i,c in ipairs(coords) do
map:add(Hex(c.x, c.y, c.z))
end
end
function Coord:createRing(radius, start)
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = (start or Coord(0,0,0)) + directions[5]
local coords = {}
for i = 1,6 do
local next = prev + directions[i]
table.insert(coords, next)
prev = next
end
return coords
end
Now then, let’s extend to allow radius other than one. Right now, we always get radius 1, no matter what we provide.
The rules of the ring are, that if we start in the southwest corner, we can go radius
tiles in each direction, and wind up back where we started.
We can move radius
tiles southwest by iterating our initial direction addition. Like this:
function Hex:createRing(radius, map)
local coords = Coord:createRing(radius)
for i,c in ipairs(coords) do
map:add(Hex(c.x, c.y, c.z))
end
end
function Coord:createRing(radius, start)
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = start or Coord(0,0,0)
for i = 1,radius do
prev = prev + directions[5]
end
local coords = {}
for i = 1,6 do
for j = 1,radius do
local next = prev + directions[i]
table.insert(coords, next)
prev = next
end
end
return coords
end
We iterate the directions[5]
step radius
times. Inside the main loop, we iterate the step and insertion radius
times. And when I change the Main to draw three rings, I get this:
So that works. However, we have no test for rings larger than one. That’s troubling. First commit: Can create rings of any radius.
Now let’s improve that method. It’s rather messy. And, did you notice, that I gave it a hidden parameter, start, to allow creating coordinate rings other than around zero? And with no tests???
I do think it’s obviously correct, but still. Anyway, let’s improve the method:
Hm whatever I did didn’t work. Let’s revert and try again.
I was trying to move the directions out of the method. Let’s move carefully. Keep an eye on me, tell me when I mess up.
function Coord:createRing(radius, start)
local directions = { Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = start or Coord(0,0,0)
for i = 1,radius do
prev = prev + directions[5]
end
local coords = {}
for i = 1,6 do
for j = 1,radius do
local next = prev + directions[i]
table.insert(coords, next)
prev = next
end
end
return coords
end
Let’s make directions
a class function:
function Coord:createRing(radius, start)
local directions = self:directions()
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = start or Coord(0,0,0)
for i = 1,radius do
prev = prev + directions[5]
end
local coords = {}
for i = 1,6 do
for j = 1,radius do
local next = prev + directions[i]
table.insert(coords, next)
prev = next
end
end
return coords
end
function Coord:directions()
return{ Coord(1,-1,0), Coord(1,0,-1), Coord(0,1,-1),Coord(-1,1,0), Coord(-1,0,1), Coord(0,-1,1) }
end
Now a method direction
to fetch just one, so that no one needs to know what the shape is:
function Coord:direction(n)
-- n = 1-6
return Coord:directions()[n]
end
And use that:
function Coord:createRing(radius, start)
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = start or Coord(0,0,0)
for i = 1,radius do
prev = prev + self:direction(5)
end
local coords = {}
for i = 1,6 do
for j = 1,radius do
local next = prev + self:direction(i)
table.insert(coords, next)
prev = next
end
end
return coords
end
All good. Commit: factor out Coord:direction and directions.
Now those loop indices are really horrid. In fact, when I originally tried this refactoring, I used i in the outer and inner loop. That didn’t work.
function Coord:createRing(radius, start)
if radius == 0 then
return start or Coord(0,0,0)
end
local prev = start or Coord(0,0,0)
for _ = 1,radius do
prev = prev + self:direction(5)
end
local coords = {}
for dir = 1,6 do
for _ = 1,radius do
local next = prev + self:direction(dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
Here, I’ve used the Lua convention of using _
as a name that’s ignored. And I’ve renamed the loop variable for the direction-choosing loop dir
. That made some sense, I thought.
Now let’s think about those loops. The first one returns a coordinate that is radius
steps away from prev
in direction 5. The second, inner loop is returning all the coordinates that are n steps away from prev in direction(dir).
Hm. Let’s see about a new function on Coord that returns a “line” of coordinates, in some direction, not including the starting one. No, that’s too hard.
Let’s extract bits of the function, giving them any reasonable name, then see what we get. I’ll start with the first loop, the one that sets up prev.
function Coord:createRing(radius, start)
local prev = start or Coord(0,0,0)
if radius == 0 then
return prev
end
local prev = Coord:stepsFrom(prev,radius,self:direction(5))
local coords = {}
for dir = 1,6 do
for _ = 1,radius do
local next = prev + self:direction(dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
function Coord:stepsFrom(aCoord, numberOfSteps, dir)
for _ = 1,numberOfSteps do
aCoord = aCoord + dir
end
return aCoord
end
Works. Commit: factor out Coord:stepsFrom
Rename a bit:
function Coord:createRing(radius, start)
start = start or Coord(0,0,0)
if radius == 0 then
return start
end
local prev = Coord:stepsFrom(start,radius,self:direction(5))
local coords = {}
for dir = 1,6 do
for _ = 1,radius do
local next = prev + self:direction(dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
Do you object to storing back into a parameter? Well, then don’t do it. I’ll allow it. Parameters are passed by value in Lua (except for tables, passed by reference). But even then the variable is safe to destroy without destroying the outer table. (Unless I’m mistaken …)
Now we can use our stepsFrom method in the inner loop, which might be better …
function Coord:createRing(radius, start)
start = start or Coord(0,0,0)
if radius == 0 then
return start
end
local prev = Coord:stepsFrom(start,radius,self:direction(5))
local coords = {}
for dirIndex = 1,6 do
local dir = self:direction(dirIndex)
for _ = 1,radius do
local next = self:stepsFrom(prev,1,dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
I fussed with the index on the outer loop and factored out the direction. I apologize for making two changes at once. Commit: using stepsFrom in createRing.
There’s no reason on earth not to iterate directly over the directions.
function Coord:createRing(radius, start)
start = start or Coord(0,0,0)
if radius == 0 then
return start
end
local prev = Coord:stepsFrom(start,radius,self:direction(5))
local coords = {}
for _,dir in ipairs(self:directions()) do
for _ = 1,radius do
local next = self:stepsFrom(prev,1,dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
That’s nicer. I am wondering if I can unwind that prev-next thing somehow. But first let’s do this:
function Coord:stepsFrom(aCoord, numberOfSteps, dir)
for _ = 1,numberOfSteps do
aCoord = self:oneStepFrom(aCoord,dir)
end
return aCoord
end
function Coord:oneStepFrom(aCoord,direction)
return aCoord + direction
end
Now we can do this:
function Coord:createRing(radius, start)
start = start or Coord(0,0,0)
if radius == 0 then
return start
end
local prev = Coord:stepsFrom(start,radius,self:direction(5))
local coords = {}
for _,dir in ipairs(self:directions()) do
for _ = 1,radius do
local next = self:oneStepFrom(prev,dir)
table.insert(coords, next)
prev = next
end
end
return coords
end
All good. Commit: refactor createRing.
Now I think I can do this:
function Coord:createRing(radius, start)
start = start or Coord(0,0,0)
if radius == 0 then
return start
end
local prev = Coord:stepsFrom(start,radius,self:direction(5))
local coords = {}
for _,dir in ipairs(self:directions()) do
for _ = 1,radius do
prev = self:oneStepFrom(prev,dir)
table.insert(coords, prev)
end
end
return coords
end
I just eliminated the next-prev thing. Tests still good. But let’s give the prev
a better name before we commit.
function Coord:createRing(radius, start)
local cell = start or Coord(0,0,0)
if radius == 0 then
return cell
end
cell = Coord:stepsFrom(cell,radius,self:direction(5))
local coords = {}
for _,direction in ipairs(self:directions()) do
for _ = 1,radius do
cell = self:oneStepFrom(cell,direction)
table.insert(coords, cell)
end
end
return coords
end
I used the name cell
and started using it right at the top. Commit: refactoring createRing.
Now one might object to the use of that mutable item cell
in the loop. I don’t quite see how to get rid of it.
And breakfast has been called. Time for Sunday morning breakfast ritual. It’s 11:19. I’ll be back soon.
Well, Soonish …
It is now 0917 Monday. I never came back yesterday. Sorry if you were waiting.
Let’s see, where were we? We can now create rings of any desired radius. We have Hex:createMap
, which creates a disc as a series of concentric rings. So that’s nice. What are some other things that we might want to do?
Oh, wait. I was babbling about the idea of a Boundary between two Tiles. That was what got me started experimenting with Hexes, because I was thinking the idea might be interesting and might improve the way Tiles work in the “real” D2 Dung game.
Now I got on that kick from observing Bryan’s mapping app, not my own. Something in the way he was working made me think that there might be a good way to handle the borders between tiles. The thing is, in my game, and probably his, and in every Hex game I’ve ever seen, it’s the whole Tile that is passable or not. Now Bryan is, I think, working on positioning Doors between Tiles, which triggered my thoughts “what else can be between tiles”, and my answer was Walls, and Nothings. That got me thinking about a generic Boundary thing.
But today, in the cold clear autumn light of Monday, I seriously doubt that I’ll want Boundaries in my game, since walls are the thickness of a Tile, and in fact they are just a kind of tile that you can’t walk on. So maybe we should ditch the idea of Boundaries.
What we’re really up against here, however, is that we have no actual purpose for these hexes. We haven’t even the rudiments of a game that we’re building. We’re just playing with the geometry of hexes (and having a good time doing it). But the risk of doing this is that what we do will never be useful. This is particularly true since I seriously doubt that I’ll one day publish a Codea Hex library which will then be taken up by all the people creating Codea hex-based games.
OK. I’ve talked myself into creating a little trivial game on these Hexes. Just now. Back at 0917, I didn’t have that in mind at all. Here at 0930, I’m going to do it. Let’s describe the “game”.
The Game
There will be a huge map of hexes, far bigger than the screen. We’ll be looking down on the map. The hexes will be painted to indicate what they are. At first, that will just be simple colors, but we’ll surely want to have artistic looking textures associated with them in due time.1
The Player will be indicated by a typical top-down view of a person, as will any other Non-Player Characters who may appear in the game. The Player can move from Hex to Hex. The view of the map will need to change based on what is or is not within view, according to some scheme about which I have no real idea right now.
There will be things in the game, to be found in the hexes. Given my rudimentary abilities in creating games, it will probably be much like Dung (D2), that is, you can do stuff but it isn’t really a hell of a lot of fun.
I can probably use the same assets, or, if not, find similar ones on line.
- A Real Challenge …
- Plugging the Hex map into the existing D2 game would be interesting. Imagine that the Big Guys Upstairs suddenly decided that our game shouldn’t be that 2.something-D square tiled game, but should use the newly popular hex tiles. The BGU come down to our next planning meeting and tell us that we have to do hexes and naturally the delivery date can’t slip.
-
That might be fun. Not in real life. In real life it would be horrid. But here in my living room it might be fun.
Wow. That’s scary. But interesting.
Scary. The whole Dung game is predicated on the Tile object, which is square. Motion and everything probably have built-in assumptions about that.
I’m going to give myself a day or two to think about this before I pick up the challenge. Certainly lots of code will have to be replaced. Is it a refactoring? No, surely actual functionality will change. But can it be done incrementally? Even sort of incrementally?
We’ll have to find out. How can we not undertake this, having thought of it. Darn thinking. Should avoid it where possible.
But For Now …
I think I’d like to put at least one more feature into the Hexes before moving to this new and ludicrous idea. I’d like to tint the hexes different colors.
There may be other things as well. I’ve had a glimmer of how to get the Hexes to draw themselves, and I’d like to try ti, maybe even try a couple of options.
Anyway, for now, tint.
Let’s start in the obvious, and obviously wrong way, by putting a color into each hex. Why obviously wrong? Well, right now, a Hex is little more than a geometric point that can return some geometric facts about itself, like the definitions of the six lines that bound it. The notion of color just doesn’t enter into it.
Let’s look at drawing right now:
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
All the drawing we have is that call to line
. The Hex provides a screen position, which is just a conversion from cube coordinates to x-y plane coordinates:
-- TODO this needs explanation and/or a diagram
function Coord: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 included that comment to remind us all that “later” and “TODO” don’t come as often as we’d like.
The boundaryLine looks like this:
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
That’s just a vertical line on the east side of the Hex, rotated to the desired angle. Sides implicitly face east, northeast, northwest, west, southwest, southeast.
Right now there’s no notion of drawing at all, much less color. But we do need to put color filling somewhere.
We’ll start by putting it into Hex. Color fill in Codea is quite limited: you can fill a rectangle or an ellipse. That’s it. But I have a cunning plan. Imagine three rectangles defining a Hex:
If we create the horizontal rectangle and draw it filled, then rotate it 60 and draw it again, then once more after another rotation, we should fill the whole hex. Unless my intuition is wrong, which it could be.
Let’s think about how to color the hexes in some interesting way. No, let’s not. Instead, let’s just color the one in the middle.
Begin with a test. The Hex needs to provide its horizontal rectangle. (We’ll have to rotate the screen to draw the other two.)
The rect
function of Codea takes x,y of the … oh, I just remembered! There is a rectMode function that allows you to provide the rectangle’s center, plus width and height. Sweet, let’s use that. That means we just need to return the necessary width and height.
No. (Hear me designing right here? You should hear the whirring.) I’m not going to write a test to return two constants. I’m going to put a fill method on Hex and let it just do the job.
Trouble …
I’ve run into a bit of trouble. Recall that our Hexes are basically of radius 1.0. That means that the rectangle we’d draw will be of width 1.732 and height 1.0. We are, of course, scaled up to some massive size when we draw: the test code has us scaled to x50.
The problem is that the rectangle drawing code can’t seem to manage drawing such a small rectangle. Scaled up, we just get a fuzzy picture:
The red lines are separately drawn to give me assurance that I’m drawing what I think I am.
Let’s try something. Let’s scale the rectangle up by 10, and scale down the picture by 10. Check this out:
function Hex:fill(aColor)
local m = modelMatrix()
local sc = m[1]
pushMatrix()
pushStyle()
local up = 10.0
local w = up*1.7320508076 -- sqrt(3)
local h = up*1.0
rectMode(CENTER)
scale(1.0/up)
stroke(255,0,0)
strokeWidth(1.0/sc)
fill(aColor)
rect(0,0,w,h)
rotate(60)
rect(0,0,w,h)
rotate(60)
rect(0,0,w,h)
popStyle()
popMatrix()
end
That gives me this:
There’s a little black border there. That’s slightly odd, because the stroke is shown as red. But let’s set it to the same color and see if that has any effect. Not really. Enough messing about, this is good enough for what I’m trying to do, which is fill the cells with a color. It’s an interesting kludge, however, and may foretell other scaling issues as we go forward.
It would be easy enough to keep our hexes as radius 1 internally and scale them up when we return screen coordinates. We’ll burn that bridge when we come to it. For now, good enough. Commit: Hex:fill(color) fills with color.
Let’s sum up and then speculate.
Summary
We just did a few nice things in this pair of sessions. We moved the code into separate tabs, did a little tidying, and then implemented the ability to create a ring, and a disc, of Hexes. We can draw those on the screen, but we’re not drawing them “inside” our code, but with ad-hoc drawing in Main.
Then we worked out how to fill a hex with color, which turned out to run into what is surely a small-number rounding issue with Codea’s internal drawing mechanism. Our hex’s sides are less than 1 in length, and it seems that drawing with fractional values and large scale values in the matrix are problematical. I’ll mention that to the implementors but I think it unlikely that it’ll change.
Coping with the problem wasn’t too difficult once I thought of scaling up and then back down again inside my drawing code. Making my rectangles 10x larger than “real” and scaling the screen down by the same amount worked well enough.
The fill was done without tests, but look at it. It’s just setting width and height to a couple of constants. Everything else is just drawing. Not much to test. If I’m wrong, please tweet me up or email me and sort me out.
Now to …
Speculation
It seems that soon, maybe as soon as tomorrow, we’ll begin converting the Dung program to run on hex tiles instead of square tiles. In so doing, I am certain that we’ll find that our design isn’t robust enough, at least in some areas, to support such a substantial change.
When that turns out to be true, we’ll learn some stuff. We’ll probably find places where the design really wasn’t that great even for square tiles. We’ll probably find other places where we could have been more ready for hexes but the design is really good enough for squares. If we do find such places, we’ll talk about whether it would have been “better” to have provided for hexes.
My philosophy of programming is that if we keep our design bright and clear, any reasonable change will be easy enough. I don’t believe we should build a game whose fundamental design notion is square tiles to support other kinds of tiles. If we have reason to believe that we’ll want hex tiles or to play on a dodecahedron or something, sure. But if it’s a square-tile game, keep it a good design for square tiles.
We’ll see what happens. It’s sure to be interesting.
I expect chaos for a while, but I’m hopeful that we can get our Princess wandering an empty space of hexagons pretty quickly. Maybe a bit beyond hopeful. Cautiously optimistic.
And for sure, we’ll learn something! See you soon!
-
Ow, that may be hard. It’s not easy to fill a hex with a solid color. Painting a texture into it might be really tricky. Oh well, we can always give up. ↩