The basic hexagonal tile-based approach explained in the hexagonal minesweeper tutorial gets the work done but is not very efficient. It uses direct conversion from the two-dimensional array-based level data and the screen coordinates, which makes it unnecessarily complicated to determine tapped tiles.
Also, the need to use different logic depending on the odd or even row/column of a tile is not convenient. This tutorial series explores the alternative screen coordinate systems which could be used to ease the logic and make things more convenient. I would strongly suggest that you read the hexagonal minesweeper tutorial before moving ahead with this tutorial as that one explains the grid rendering based on a two-dimensional array.
1. Axial Coordinates
The default approach used for screen coordinates in the hexagonal minesweeper tutorial is called the offset coordinate approach. This is because the alternative rows or columns are offset by a value while aligning the hexagonal grid.
To refresh your memory, please refer to the image below, which shows the horizontal alignment with offset coordinate values displayed.
In the image above, a row with the same i
value is highlighted in red, and a column with same j
value is highlighted in green. To make everything simple, we won't be discussing the odd and even offset variants as both are just different ways to get the same result.
Let me introduce a better screen coordinate alternative, the axial coordinate. Converting an offset coordinate to an axial variant is very simple. The i
value remains the same, but the j
value is converted using the formula axialJ = i - floor(j/2)
. A simple method can be used to convert an offset Phaser.Point
to its axial variant, as shown below.
function offsetToAxial(offsetPoint){ offsetPoint.y=(offsetPoint.y-(Math.floor(offsetPoint.x/2))); return offsetPoint; }
The reverse conversion would be as shown below.
function axialToOffset(axialPoint){ axialPoint.y=(axialPoint.y+(Math.floor(axialPoint.x/2))); return axialPoint; }
Here the x
value is the i
value, and y
value is the j
value for the two-dimensional array. After conversion, the new values would look like the image below.
Notice that the green line where the j
value remains the same does not zigzag anymore, but rather is now a diagonal to our hexagonal grid.
For the vertically aligned hexagonal grid, the offset coordinates are displayed in the image below.
The conversion to axial coordinates follows the same equations, with the difference that we keep the j
value the same and alter the i
value. The method below shows the conversion.
function offsetToAxial(offsetPoint){ offsetPoint.x=(offsetPoint.x-(Math.floor(offsetPoint.y/2))); return offsetPoint; }
The result is as shown below.
Before we use the new coordinates to solve problems, let me quickly introduce you to another screen coordinate alternative: cube coordinates.
2. Cube or Cubic Coordinates
Straightening up the zigzag itself has potentially solved most of the inconveniences we had with the offset coordinate system. Cube or cubic coordinates would further assist us in simplifying complicated logic like heuristics or rotating around a hexagonal cell.
As you may have guessed from the name, the cubic system has three values. The third k
or z
value is derived from the equation x+y+z=0
, where x
and y
are the axial coordinates. This leads us to this simple method to calculate the z
value.
function calculateCubicZ(axialPoint){ return -axialPoint.x-axialPoint.y; }
The equation x+y+z=0
is actually a 3D plane which passes through the diagonal of a three-dimensional cube grid. Displaying all three values for the grid will result in the following images for the different hexagonal alignments.
The blue line indicates the tiles where the z
value remains the same.
3. Advantages of the New Coordinate System
You may be wondering how these new coordinate systems help us with hexagonal logic. I will explain a few benefits before we move on to create a hexagonal Tetris using our new knowledge.
Movement
Let's consider the middle tile in the image above, which has cubic coordinate values of 3,6,-9
. We have noticed that one coordinate value remains the same for the tiles on the coloured lines. Further, we can see that the remaining coordinates either increase or decrease by 1 while tracing any of the coloured lines. For example, if the x
value remains the same and the y
value increases by 1 along a direction, the z
value decreases by 1 to satisfy our governing equation x+y+z=0
. This feature makes controlling movement much easier. We will put this to use in the second part of the series.
Neighbours
By the same logic, it is straightforward to find the neighbours for tile x,y,z
. By keeping x
the same, we get two diagonal neighbours, x,y-1,z+1
and x,y+1,z-1
. By keeping y the same, we get two vertical neighbours, x-1,y,z+1
and x+1,y,z-1
. By keeping z the same, we get the remaining two diagonal neighbours, x+1,y-1,z
and x-1,y+1,z
. The image below illustrates this for a tile at the origin.
It is so much easier now that we don't need to use different logic based on even or odd rows/columns.
Moving Around a Tile
One interesting thing to notice in the above image is a kind of cyclic symmetry for all the tiles around the red tile. If we take the coordinates of any neighbouring tile, the coordinates of the immediate neighbouring tile can be obtained by cycling the coordinate values either left or right and then multiplying by -1.
For example, the top neighbour has a value of -1,0,1
, which on rotating right once becomes 1,-1,0
and after multiplying by -1 becomes -1,1,0
, which is the coordinate of the right neighbour. Rotating left and multiplying by -1 yields 0,-1,1
, which is the coordinate of the left neighbour. By repeating this, we can jump between all the neighbouring tiles around the centre tile. This is a very interesting feature which could assist in logic and algorithms.
Note that this is happening only due to the fact that the middle tile is considered to be at the origin. We could easily make any tile x,y,z
to be at the origin by subtracting the values x
, y
and z
from it and all other tiles.
Heuristics
Calculating efficient heuristics is key when it comes to pathfinding or similar algorithms. Cubic coordinates make it easier to find simple heuristics for hexagonal grids due to the aspects mentioned above. We will discuss this in detail in the second part of this series.
These are some of the advantages of the new coordinate system. We could use a mix of the different coordinate systems in our practical implementations. For example, the two-dimensional array is still the best way to save the level data, the coordinates of which are the offset coordinates.
Let's try to create a hexagonal version of the famous Tetris game using this new knowledge.
4. Creating a Hexagonal Tetris
We have all played Tetris, and if you are a game developer, you may have created your own version as well. Tetris is one of the easiest tile-based games one can implement, apart from tic tac toe or checkers, using a simple two-dimensional array. Let's first list the features of Tetris.
- It starts with a blank two-dimensional grid.
- Different blocks appear at the top and move down one tile at a time until they reach the bottom.
- Once they reach the bottom, they get cemented there or become non-interactive. Basically, they become part of the grid.
- While dropping down, the block can be moved sideways, rotated clockwise/anticlockwise, and dropped down.
- The objective is to fill up all the tiles in any row, upon which the whole row disappears, collapsing the rest of the filled grid onto it.
- The game ends when there are no more free tiles on top for a new block to enter the grid.
Representing the Different Blocks
As the game has blocks dropping vertically, we will use a vertically aligned hexagonal grid. This means that moving them sideways will make them move in a zigzag manner. A full row in the grid consists of a set of tiles in zigzag order. From this point onwards, you may start referring to the source code provided along with this tutorial.
The level data is stored in a two-dimensional array named levelData
, and the rendering is done using the offset coordinates, as explained in the hexagonal minesweeper tutorial. Please refer to it if you're having difficulty following the code.
The interactive element in the next section shows the different blocks which we are going to use. There is one more additional block, which consists of three filled tiles aligned vertically like a pillar. BlockData
is used to create the different blocks.
function BlockData(topB,topRightB,bottomRightB,bottomB,bottomLeftB,topLeftB){ this.tBlock=topB; this.trBlock=topRightB; this.brBlock=bottomRightB; this.bBlock=bottomB; this.blBlock=bottomLeftB; this.tlBlock=topLeftB; this.mBlock=1; }
A blank block template is a set of seven tiles consisting of a middle tile surrounded by its six neighbours. For any Tetris block, the middle tile is always filled denoted by a value of 1
, whereas an empty tile would be denoted by a value of 0
. The different blocks are created by populating the tiles of BlockData
as below.
var block1= new BlockData(1,1,0,0,0,1); var block2= new BlockData(0,1,0,0,0,1); var block3= new BlockData(1,1,0,0,0,0); var block4= new BlockData(1,1,0,1,0,0); var block5= new BlockData(1,0,0,1,0,1); var block6= new BlockData(0,1,1,0,1,1); var block7= new BlockData(1,0,0,1,0,0);
We have a total of seven different blocks.
Rotating the Blocks
Let me show you how the blocks rotate using the interactive element below. Tap and hold to rotate the blocks, and tap x
to change the direction of rotation.
To rotate the block, we need to find all the tiles which have a value of 1
, set the value to 0
, rotate once around the middle tile to find the neighbouring tile, and set its value to 1
. To rotate a tile around another tile, we can use the logic explained in the moving around a tile section above. We arrive at the below method for this purpose.
function rotateTileAroundTile(tileToRotate, anchorTile){ tileToRotate=offsetToAxial(tileToRotate);//convert to axial var tileToRotateZ=calculateCubicZ(tileToRotate);//find z value anchorTile=offsetToAxial(anchorTile);//convert to axial var anchorTileZ=calculateCubicZ(anchorTile);//find z value tileToRotate.x=tileToRotate.x-anchorTile.x;//find x difference tileToRotate.y=tileToRotate.y-anchorTile.y;//find y difference tileToRotateZ=tileToRotateZ-anchorTileZ;//find z difference var pointArr=[tileToRotate.x,tileToRotate.y,tileToRotateZ];//populate array to rotate pointArr=arrayRotate(pointArr,clockWise);//rotate array, true for clockwise tileToRotate.x=(-1*pointArr[0])+anchorTile.x;//multiply by -1 & remove the x difference tileToRotate.y=(-1*pointArr[1])+anchorTile.y;//multiply by -1 & remove the y difference tileToRotate=axialToOffset(tileToRotate);//convert to offset return tileToRotate; } //... function arrayRotate(arr, reverse){//nifty method to rotate array elements if(reverse) arr.unshift(arr.pop()) else arr.push(arr.shift()) return arr }
The variable clockWise
is used to rotate clockwise or anticlockwise, which is accomplished by moving the array values in opposite directions in arrayRotate
.
Moving the Block
We keep track of the i
and j
offset coordinates for the middle tile of the block using the variables blockMidRowValue
and blockMidColumnValue
respectively. In order to move the block, we increment or decrement these values. We update the corresponding values in levelData
with the block values using the paintBlock
method. The updated levelData
is used to render the scene after each state change.
var blockMidRowValue; var blockMidColumnValue; //... function moveLeft(){ blockMidColumnValue--; } function moveRight(){ blockMidColumnValue++; } function dropDown(){ paintBlock(true); blockMidRowValue++; } function paintBlock(){ clockWise=true; var val=1; changeLevelData(blockMidRowValue,blockMidColumnValue,val); var rotatingTile=new Phaser.Point(blockMidRowValue-1,blockMidColumnValue); if(currentBlock.tBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.tBlock); } var midPoint=new Phaser.Point(blockMidRowValue,blockMidColumnValue); rotatingTile=rotateTileAroundTile(rotatingTile,midPoint); if(currentBlock.trBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.trBlock); } midPoint.x=blockMidRowValue; midPoint.y=blockMidColumnValue; rotatingTile=rotateTileAroundTile(rotatingTile,midPoint); if(currentBlock.brBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.brBlock); } midPoint.x=blockMidRowValue; midPoint.y=blockMidColumnValue; rotatingTile=rotateTileAroundTile(rotatingTile,midPoint); if(currentBlock.bBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.bBlock); } midPoint.x=blockMidRowValue; midPoint.y=blockMidColumnValue; rotatingTile=rotateTileAroundTile(rotatingTile,midPoint); if(currentBlock.blBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.blBlock); } midPoint.x=blockMidRowValue; midPoint.y=blockMidColumnValue; rotatingTile=rotateTileAroundTile(rotatingTile,midPoint); if(currentBlock.tlBlock==1){ changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.tlBlock); } } function changeLevelData(iVal,jVal,newValue,erase){ if(!validIndexes(iVal,jVal))return; if(erase){ if(levelData[iVal][jVal]==1){ levelData[iVal][jVal]=0; } }else{ levelData[iVal][jVal]=newValue; } } function validIndexes(iVal,jVal){ if(iVal<0 || jVal<0 || iVal>=levelData.length || jVal>=levelData[0].length){ return false; } return true; }
Here, currentBlock
points to the blockData
in the scene. In paintBlock
, first we set the levelData
value for the middle tile of the block to 1
as it is always 1
for all blocks. The index of the midpoint is blockMidRowValue
, blockMidColumnValue
.
Then we move to the levelData
index of the tile on top of the middle tile blockMidRowValue-1
, blockMidColumnValue
, and set it to 1
if the block has this tile as 1
. Then we rotate clockwise once around the middle tile to get the next tile and repeat the same process. This is done for all the tiles around the middle tile for the block.
Checking Valid Operations
While moving or rotating the block, we need to check if that is a valid operation. For example, we cannot move or rotate the block if the tiles it needs to occupy are already occupied. Also, we cannot move the block outside our two-dimensional grid. We also need to check if the block can drop any further, which would determine if we need to cement the block or not.
For all of these, I use a method canMove(i,j)
, which returns a boolean indicating if placing the block at i,j
is a valid move. For every operation, before actually changing the levelData
values, we check if the new position for the block is a valid position using this method.
function canMove(iVal,jVal){ var validMove=true; var store=clockWise; var newBlockMidPoint=new Phaser.Point(blockMidRowValue+iVal,blockMidColumnValue+jVal); clockWise=true; if(!validAndEmpty(newBlockMidPoint.x,newBlockMidPoint.y)){//check mid, always 1 validMove=false; } var rotatingTile=new Phaser.Point(newBlockMidPoint.x-1,newBlockMidPoint.y); if(currentBlock.tBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){//check top validMove=false; } } newBlockMidPoint.x=blockMidRowValue+iVal; newBlockMidPoint.y=blockMidColumnValue+jVal; rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint); if(currentBlock.trBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){ validMove=false; } } newBlockMidPoint.x=blockMidRowValue+iVal; newBlockMidPoint.y=blockMidColumnValue+jVal; rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint); if(currentBlock.brBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){ validMove=false; } } newBlockMidPoint.x=blockMidRowValue+iVal; newBlockMidPoint.y=blockMidColumnValue+jVal; rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint); if(currentBlock.bBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){ validMove=false; } } newBlockMidPoint.x=blockMidRowValue+iVal; newBlockMidPoint.y=blockMidColumnValue+jVal; rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint); if(currentBlock.blBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){ validMove=false; } } newBlockMidPoint.x=blockMidRowValue+iVal; newBlockMidPoint.y=blockMidColumnValue+jVal; rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint); if(currentBlock.tlBlock==1){ if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){ validMove=false; } } clockWise=store; return validMove; } function validAndEmpty(iVal,jVal){ if(!validIndexes(iVal,jVal)){ return false; }else if(levelData[iVal][jVal]>1){//occuppied return false; } return true; }
The process here is the same as paintBlock
, but instead of altering any values, this just returns a boolean indicating a valid move. Although I am using the rotation around a middle tile logic to find the neighbours, the easier and quite efficient alternative is to use the direct coordinate values of the neighbours, which can be easily determined from the middle tile coordinates.
Rendering the Game
The game level is visually represented by a RenderTexture
named gameScene
. In the array levelData
, an unoccupied tile would have a value of 0
, and an occupied tile would have a value of 2
or higher.
A cemented block is denoted by a value of 2
, and a value of 5
denotes a tile which needs to be removed as it is part of a completed row. A value of 1
means that the tile is part of the block. After each game state change, we render the level using the information in levelData
, as shown below.
//.. hexSprite.tint='0xffffff'; if(levelData[i][j]>-1){ axialPoint=offsetToAxial(axialPoint); cubicZ=calculateCubicZ(axialPoint); if(levelData[i][j]==1){ hexSprite.tint='0xff0000'; }else if(levelData[i][j]==2){ hexSprite.tint='0x0000ff'; }else if(levelData[i][j]>2){ hexSprite.tint='0x00ff00'; } gameScene.renderXY(hexSprite,startX, startY, false); } //...
Hence a value of 0
is rendered without any tint, a value of 1
is rendered with red tint, a value of 2
is rendered with blue tint, and a value of 5
is rendered with green tint.
5. The Completed Game
Putting everything together, we get the completed hexagonal Tetris game. Please go through the source code to understand the complete implementation. You will notice that we are using both offset coordinates and cubic coordinates for different purposes. For example, to find if a row is completed, we make use of offset coordinates and check the levelData
rows.
Conclusion
This concludes the first part of the series. We have successfully created a hexagonal Tetris game using a combination of offset coordinates, axial coordinates, and cube coordinates.
In the concluding part of the series, we'll learn about character movement using the new coordinates on a horizontally aligned hexagonal grid.