Traversing grids
The order in which hexes in a grid are iterated is the same as the order in which they were added to the grid. And because a grid represents a 2D map of hexes in contrast with a 1D list of things (like an array), it makes sense to iterate over a portion of the grid and in a specific order. This is why Honeycomb has traversers.
What is a traverser?
A traverser is a function that produces hexes in a specific order. When passed to a grid's traverse()
method, it's a powerful way to iterate over a subset of hexes in a grid in a specific order.
Details
More specifically, a traverser is a function that accepts a hex factory and an optional cursor and returns an iterable of hexes:
type Traverser = (
// hex factory: a function that creates a hex
createHex: (coordinates?: HexCoordinates) => Hex,
// cursor: so that the next traverser knows where to continue traversing
cursor?: HexCoordinates,
) => Iterable<Hex>
An example:
const Hex = defineHex({ dimensions: 30 })
const grid = new Grid(Hex, rectangle({ width: 5, height: 5 }))
const spiralTraverser = spiral({ start: [0, 2], radius: 1 })
grid.traverse(spiralTraverser)
- Line 2: create a rectangular grid of 5⨉5 hexes.
- Line 3: create a traverser with the built-in
spiral()
function (spiral()
returns a traverser). The spiral starts at the hex with coordinates[0, 2]
and runs outward until its radius of 1 hex (excluding the center hex) is reached for a total of 7 hexes. - Line 5:
grid.traverse()
internally callsspiralTraverser
and loops over the hexes it produces. Only the hexes that are present ingrid
are returned in a new grid. In this example all hexes produced by the traverser are also present ingrid
.
This can be visualized like so:
When the radius is increased from 1
to 2
it becomes apparent that only the hexes present in grid
are traversed. You see that the traversal "jumps" from [-2, 4]
to [0, 0]
:
After [-2, 4]
the traverser would've wanted to go to [-2, 3]
(North West of [-2, 4]
) and then to [-2, 2]
(another step NW), but these hexes don't exist in grid
and are skipped. That's until [0, 0]
is traversed, which is present and that also happens to finish the spiral.
Bail a traversal
grid.traverse()
accepts a second parameter that stops traversing when the traverser produces a hex that's not present in the grid.
grid.traverse(spiralTraverser, { bail: true })
Note that it didn't "jump" from [-2, 4]
to [0, 0]
this time. After [-2, 4]
the traverser would've wanted to go to [-2, 3]
(which is North West of [-2, 4]
and not present in grid
) and bailed.
Combining traversers
Any function or method that accepts a traverser, also accepts an array of traversers. This makes it possible to create more complex traversals. For example, you could traverse a 5⨉5 grid with the outline of a square:
const grid = new Grid(Hex, rectangle({ width: 5, height: 5 }))
const squareOutlineTraverser = [
line({ direction: Direction.E, length: 4 }),
line({ direction: Direction.S, length: 3 }),
line({ direction: Direction.W, length: 3 }),
line({ direction: Direction.N, length: 3 }),
]
grid.traverse(squareOutlineTraverser)
Note that line()
is never called with a start
option. Most functions that create traversers, including line()
and rectangle()
, accept a start
option. But it's optional. When it's not passed it either starts where the previous traverser left off (this happens on lines 4, 5, and 6 in the example above, that's why they have a length of 3
). Or if it's the first traverser (line 3 in the example above), start
defaults to [0, 0]
.
This is what happens when start: [1, 1]
is passed to the first line()
:
const squareOutlineTraverser = [
line({ start: [1, 1], direction: Direction.E, length: 4 }),
line({ direction: Direction.S, length: 3 }),
line({ direction: Direction.W, length: 3 }),
line({ direction: Direction.N, length: 3 }),
]
grid.traverse(squareOutlineTraverser)
Built-in traversers
Honeycomb has several functions to create traversers, they are: concat()
, fromCoordinates()
, line()
, move()
, rectangle()
, repeat()
, repeatWith()
, ring()
and spiral()
.
concat()
This is mostly used internally to combine (concatenate) traversers. Putting some traversers in an array doesn't magically tie them together, that's what concat()
is for:
// this is just an array of traversers:
const traversers = [line({ … }), rectangle({ … })];
// [function lineTraverser() { … }, function rectangleTraverser() { … }]
// this composes them into a single traverser:
concat(traversers) // function concatTraverser() { … }
fromCoordinates()
Probably the simplest traverser. It accepts any number of hex coordinates and returns a traverser that produces hexes with those coordinates:
const someHexes = fromCoordinates(
[1, 3],
{ q: 4, r: 0 },
{ col: 0, row: 2 }
)
grid.traverse(someHexes)
line()
A line traverser can be created in two ways:
- With "vector options":
const vector = line({ start: [1, 0], direction: Direction.SE, length: 4 })
grid.traverse(vector)
When the direction is ambiguous (North and South for pointy hexes, West and East for flat hexes), the next hex is chosen based on the offset setting.
- With "between hexes options":
const lineBetween = line({ start: [2, 0], stop: [1, 4] })
grid.traverse(lineBetween)
This uses interpolation to determine which hexes are on the line.
move()
It only accepts a direction and can be used to "move" the cursor a single hex in that direction:
const firstHex = fromCoordinates([1, 1])
const moveSouth = move(Direction.S)
const moveEast = move(Direction.E)
grid.traverse([firstHex, moveSouth, moveEast])
It's equivalent to line({ direction, length: 1 })
and can be used to make more complex traversers.
When the direction is ambiguous (North and South for pointy hexes, West and East for flat hexes), the hex is chosen based on the offset setting.
rectangle()
A rectangle traverser can be created in two ways:
- With "rectangle options":
const square = rectangle({ start: [1, 1], width: 3, height: 3 })
grid.traverse(square)
It's possible to change the direction of the "rows":
const square = rectangle({
start: [0, 3],
width: 3,
height: 3,
direction: Direction.N
})
grid.traverse(square)
WARNING
When you pass an ordinal direction (NE, SE, SW or NW), you get a diamond shape:
const square = rectangle({
start: [0, 2],
width: 3,
height: 3,
direction: Direction.NE
})
grid.traverse(square)
- With opposing corners:
const rect = rectangle([-1, 4], [3, 1])
grid.traverse(rect)
repeat()
Another "helper" traverser. It accepts a number and a traverser (or multiple traversers) to repeat those traverser(s) the given number of times:
const fiveStepsSE = repeat(3, move(Direction.SE))
grid.traverse([fromCoordinates([2, 0]), fiveStepsSE])
A more complex example:
const rightLeft = [
line({ direction: Direction.E, length: 2 }),
move(Direction.S),
line({ direction: Direction.W, length: 2 }),
move(Direction.S)
]
const zigZag = repeat(2, rightLeft)
grid.traverse([
fromCoordinates([1, 0]),
zigZag,
line({ direction: Direction.E, length: 2 })
])
repeatWith()
Similar to repeat()
, this traverser "multiplies" other traversers. However, repeatWith()
takes a "source traverser" (or multiple "source traversers") and a "branch traverser" (or multiple "branch traversers"). It iterates over the hexes produced by the source traverser(s) and passes them as cursors to the branch traverser(s). The rectangle()
function internally uses repeatWith()
like so:
const width = 3
const height = 3
const square = repeatWith(
line({ start: [1, 1], direction: Direction.S, length: height }),
line({ direction: Direction.E, length: width - 1 }),
)
grid.traverse(square)
The source traverser (line 4) is a line going South and for each hex it produces, a line in a perpendicular direction (East) is created. Because the hexes from the source traverser are included, the branch traverser can be one hex shorter. repeatWith()
accepts a third argument to exclude the hexes created by the source traverser(s). Here's the same square, but with { includeSource: false }
:
const width = 3
const height = 3
const square = repeatWith(
line({ start: [0, 1], direction: Direction.S, length: height }),
line({ direction: Direction.E, length: width }),
{ includeSource: false },
)
grid.traverse(square)
Apart from the added third argument, two other things are different from the previous example. start
on line 4 is shifted one hex to the West, because no hex from that line()
is included in the result anymore. And length
on line 5 can now just be width
, again, because the first "column" of hexes from the source traverser is missing.
ring()
A ring traverser can be created in two ways:
- With "ring options":
const someRing = ring({ start: [1, 4], center: [1, 2] })
grid.traverse(someRing)
The direction of rotation can be changed as well:
const ccwRing = ring({
start: [1, 4],
center: [1, 2],
rotation: Rotation.COUNTERCLOCKWISE
})
grid.traverse(ccwRing)
- With "radius options":
const radiusRing = ring({ center: [1, 2], radius: 2 })
grid.traverse(radiusRing)
Note that when passing a radius, it's not possible to control where the ring starts (it'll always start East of the center).
spiral()
A spiral has one required option: radius
. But you may want to pass start
as well. If you don't it'll start where the previous traverser left off, or at [0, 0]
if there is no previous traverser.
const spiralFrom1_2 = spiral({ start: [1, 2], radius: 2 })
grid.traverse(spiralFrom1_2)
Just as with ring()
, the radius excludes the center. And also just as with ring()
, the rotation can be counterclockwise:
const ccwSpiral = spiral({
start: [1, 2],
radius: 2
rotation: Rotation.COUNTERCLOCKWISE
})
grid.traverse(ccwSpiral)