There are few operations one might need to perform:
- finding hexagon's position by its index in the grid;
- picking a hexagon by mouse;
- finding neighbor cells;
- finding hexagon's corner coordinates etc.
Having tried to consult with internets I've found a neat article on CodeProject, which was solving exactly the problems mentioned. It's considerably highy rated, which I guess means that this stuff is indeed in demand.
What have confused me, though, is that presented solution (and code) seemed to be more complex than one could expect:
- hexagon positions are found iteratively, based on the previously computed positions;
- a bunch of corner cases are treated in the process;
- a whole lot of state is stored for each hexagon;
- there is a separate codepath for the alternative hexagon orientation mode (aka "pointy" orientation), which interwines with the main one;
- to pick a hexagon by mouse, the code iterates on the array of hexagons, and performs generic "point-in-polygon" test for each, using stored corners' coordinates.
Can we do any simpler?..
Hexagonal grid properties
Let's examine the geometric properties of a hexagon and define some constants:We define the hexagon by its radius R, and find some other parameters based on it, such as W ("width"), S ("side") and H ("height").
Now, let's examine the hexagonal grid itself:
Finding hexagon position by its array index
From that picture one can figure out the formula for finding position of the top left corner of a hexagon cell by its array index (i, j):Taking into account that for odd columns i%2 (remainder from division of i by 2) equals 1, and for even columns it equals 0, we can rewrite it:
Finding hexagon index by a point
Another operation is to find the hexagon array index from a point coordinates on the screen, which is used for mouse picking.Let's start from the observation that hexagonal grid can be completely covered by the set of rectangular tiles (of width S and height H). They are drawn as green rectangles with purple triangular insets in the picture above. Each tile overlaps with three hexagons.
Finding which of those tiles our picking point gets into is not too hard:
Coordinates of the point inside the tile are:
For all the other points inside the rectangle, we have to pick either top or bottom hexagon to the left. This is decided based on value of yt.
Aditionally, we have to mind the odd/even rows indexing difference (as rows in the hexagon array go in zigzag pattern).
Putting it all together, the final array indices of the picked hexagon are:
The "pointy" grid orientation
So far we've been only considering the "flat" grid orientation (i.e. hexagons are "lying" on their sides).What happens if we want to have the alternative orientation, when hexagons are oriented corners upwards, should we rewrite all the formulas?..
One simple observation about the "pointy" orientation is that one can get it by mirroring the "flat" one around the diagonal axis.
The direct consequence of this is that all the calculations for the "pointy" case can be performed by:
- swapping the input coordinates (i.e. "mirror" them around the diagonal): x<->y or i<->j;
- applying the formulas as described above;
- swapping the output coordinates back ("unmirroring" them): i<->j, x<->y.
The code
Here's an example Java code, which has the formulas implemented:package com.rush; /** * Uniform hexagonal grid cell's metrics utility class. */ public class HexGridCell { private static final int[] NEIGHBORS_DI = { 0, 1, 1, 0, -1, -1 }; private static final int[][] NEIGHBORS_DJ = { { -1, -1, 0, 1, 0, -1 }, { -1, 0, 1, 1, 1, 0 } }; private final int[] CORNERS_DX; // array of horizontal offsets of the cell's corners private final int[] CORNERS_DY; // array of vertical offsets of the cell's corners private final int SIDE; private int mX = 0; // cell's left coordinate private int mY = 0; // cell's top coordinate private int mI = 0; // cell's horizontal grid coordinate private int mJ = 0; // cell's vertical grid coordinate /** * Cell radius (distance from center to one of the corners) */ public final int RADIUS; /** * Cell height */ public final int HEIGHT; /** * Cell width */ public final int WIDTH; public static final int NUM_NEIGHBORS = 6; /** * @param radius Cell radius (distance from the center to one of the corners) */ public HexGridCell(int radius) { RADIUS = radius; WIDTH = radius * 2; HEIGHT = (int) (((float) radius) * Math.sqrt(3)); SIDE = radius * 3 / 2; int cdx[] = { RADIUS / 2, SIDE, WIDTH, SIDE, RADIUS / 2, 0 }; CORNERS_DX = cdx; int cdy[] = { 0, 0, HEIGHT / 2, HEIGHT, HEIGHT, HEIGHT / 2 }; CORNERS_DY = cdy; } /** * @return X coordinate of the cell's top left corner. */ public int getLeft() { return mX; } /** * @return Y coordinate of the cell's top left corner. */ public int getTop() { return mY; } /** * @return X coordinate of the cell's center */ public int getCenterX() { return mX + RADIUS; } /** * @return Y coordinate of the cell's center */ public int getCenterY() { return mY + HEIGHT / 2; } /** * @return Horizontal grid coordinate for the cell. */ public int getIndexI() { return mI; } /** * @return Vertical grid coordinate for the cell. */ public int getIndexJ() { return mJ; } /** * @return Horizontal grid coordinate for the given neighbor. */ public int getNeighborI(int neighborIdx) { return mI + NEIGHBORS_DI[neighborIdx]; } /** * @return Vertical grid coordinate for the given neighbor. */ public int getNeighborJ(int neighborIdx) { return mJ + NEIGHBORS_DJ[mI % 2][neighborIdx]; } /** * Computes X and Y coordinates for all of the cell's 6 corners, clockwise, * starting from the top left. * * @param cornersX Array to fill in with X coordinates of the cell's corners * @param cornersX Array to fill in with Y coordinates of the cell's corners */ public void computeCorners(int[] cornersX, int[] cornersY) { for (int k = 0; k < NUM_NEIGHBORS; k++) { cornersX[k] = mX + CORNERS_DX[k]; cornersY[k] = mY + CORNERS_DY[k]; } } /** * Sets the cell's horizontal and vertical grid coordinates. */ public void setCellIndex(int i, int j) { mI = i; mJ = j; mX = i * SIDE; mY = HEIGHT * (2 * j + (i % 2)) / 2; } /** * Sets the cell as corresponding to some point inside it (can be used for * e.g. mouse picking). */ public void setCellByPoint(int x, int y) { int ci = (int)Math.floor((float)x/(float)SIDE); int cx = x - SIDE*ci; int ty = y - (ci % 2) * HEIGHT / 2; int cj = (int)Math.floor((float)ty/(float)HEIGHT); int cy = ty - HEIGHT*cj; if (cx > Math.abs(RADIUS / 2 - RADIUS * cy / HEIGHT)) { setCellIndex(ci, cj); } else { setCellIndex(ci - 1, cj + (ci % 2) - ((cy < HEIGHT / 2) ? 1 : 0)); } } } |
Testing the code
To test the code I've written a small Java applet.It's a hexagonal version of the game called "Lights Out" (the idea of which I've borrowed from here).
One starts the game with all the "lights" on (all the hexagons are yellow), and the goal is to switch all of them off (so all the hexagons become gray).
Whenever user clicks on a hexagon, it toggles its light, together with all the neighbor hexagons.
package com.rush; import java.applet.Applet; import java.awt.Color; import java.awt.Graphics; import java.awt.event.MouseEvent; import java.awt.event.MouseListener; import javax.swing.JFrame; import javax.swing.JOptionPane; /** * Example applet which uses hexagonal grid. It's a hexagonal version of the * "lights out" puzzle game: http://en.wikipedia.org/wiki/Lights_Out_(game) */ public class HexLightsOut extends Applet implements MouseListener { private static final long serialVersionUID = 1L; private static final int BOARD_WIDTH = 5; private static final int BOARD_HEIGHT = 4; private static final int L_ON = 1; private static final int L_OFF = 2; private static final int NUM_HEX_CORNERS = 6; private static final int CELL_RADIUS = 40; // game board cells array private int[][] mCells = { { 0, L_ON, L_ON, L_ON, 0 }, { L_ON, L_ON, L_ON, L_ON, L_ON }, { L_ON, L_ON, L_ON, L_ON, L_ON }, { 0, 0, L_ON, 0, 0 } }; private int[] mCornersX = new int[NUM_HEX_CORNERS]; private int[] mCornersY = new int[NUM_HEX_CORNERS]; private static HexGridCell mCellMetrics = new HexGridCell(CELL_RADIUS); @Override public void init() { addMouseListener(this); } @Override public void paint(Graphics g) { for (int j = 0; j < BOARD_HEIGHT; j++) { for (int i = 0; i < BOARD_WIDTH; i++) { mCellMetrics.setCellIndex(i, j); if (mCells[j][i] != 0) { mCellMetrics.computeCorners(mCornersX, mCornersY); g.setColor((mCells[j][i] == L_ON) ? Color.ORANGE : Color.GRAY); g.fillPolygon(mCornersX, mCornersY, NUM_HEX_CORNERS); g.setColor(Color.BLACK); g.drawPolygon(mCornersX, mCornersY, NUM_HEX_CORNERS); } } } } @Override public void update(Graphics g) { paint(g); } /** * Returns true if the cell is inside the game board. * * @param i cell's horizontal index * @param j cell's vertical index */ private boolean isInsideBoard(int i, int j) { return i >= 0 && i < BOARD_WIDTH && j >= 0 && j < BOARD_HEIGHT && mCells[j][i] != 0; } /** * Toggles the cell's light ON<->OFF. */ private void toggleCell(int i, int j) { mCells[j][i] = (mCells[j][i] == L_ON) ? L_OFF : L_ON; } /** * Returns true if all lights have been switched off. */ private boolean isWinCondition() { for (int j = 0; j < BOARD_HEIGHT; j++) { for (int i = 0; i < BOARD_WIDTH; i++) { if (mCells[j][i] == L_ON) { return false; } } } return true; } /** * Resets the game to the initial position (all lights are on). */ private void resetGame() { for (int j = 0; j < BOARD_HEIGHT; j++) { for (int i = 0; i < BOARD_WIDTH; i++) { if (mCells[j][i] == L_OFF) { mCells[j][i] = L_ON; } } } } @Override public void mouseReleased(MouseEvent arg0) { mCellMetrics.setCellByPoint(arg0.getX(), arg0.getY()); int clickI = mCellMetrics.getIndexI(); int clickJ = mCellMetrics.getIndexJ(); if (isInsideBoard(clickI, clickJ)) { // toggle the clicked cell together with the neighbors toggleCell(clickI, clickJ); for (int k = 0; k < 6; k++) { int nI = mCellMetrics.getNeighborI(k); int nJ = mCellMetrics.getNeighborJ(k); if (isInsideBoard(nI, nJ)) { toggleCell(nI, nJ); } } } repaint(); if (isWinCondition()) { JOptionPane.showMessageDialog(new JFrame(), "Well done!"); resetGame(); repaint(); } } @Override public void mouseClicked(MouseEvent arg0) { } @Override public void mouseEntered(MouseEvent arg0) { } @Override public void mouseExited(MouseEvent arg0) { } @Override public void mousePressed(MouseEvent arg0) { } } |
The source is also available on github.
The applet in action can be seen here (all the usual java applet related disclaimers apply).
UPDATE: Ferris Thomas made an ActionScript 3 port of the code, kudos for that!
Additional thanks to Ruurd for spotting typos in the formulas.