In this tutorial, we will be converting a conventional 2D tile-based Sokoban game into isometric and hexagonal views. If you are new to isometric or hexagonal games, it may be overwhelming at first to try following through both of them at the same time. In that case, I recommend choosing isometric first and then coming back at a later stage for the hexagonal version.
We will be building on top of the earlier Unity tutorial: Unity 2D Tile-Based Sokoban Game. Please go through the tutorial first as most of the code remains unchanged and all the core concepts remain the same. I'll also link to other tutorials explaining some of the underlying concepts.
The most important aspect in creating isometric or hexagonal versions from a 2D version is figuring out the positioning of the elements. We will use conversion methods based on equations to convert between the various coordinate systems.
This tutorial has two sections, one for the isometric version and the other for the hexagonal version.
1. Isometric Sokoban Game
Let's dive right in to the isometric version once you have gone through the original tutorial. The image below shows how the isometric version would look, provided we use the same level information used in the original tutorial.
Isometric View
Isometric theory, conversion equation, and implementation are explained in multiple tutorials on Envato Tuts+. An old Flash-based explanation can be found in this detailed tutorial. I would recommend this Phaser-based tutorial as it is more recent and future proof.
Although the scripting languages used in those tutorials are ActionScript 3 and JavaScript respectively, the theory is applicable everywhere, irrespective of programming languages. Essentially it boils down to these conversion equations which are to be used to convert 2D Cartesian coordinates to isometric coordinates or vice versa.
//Cartesian to isometric: isoX = cartX - cartY; isoY = (cartX + cartY) / 2; //Isometric to Cartesian: cartX = (2 * isoY + isoX) / 2; cartY = (2 * isoY - isoX) / 2;
We will be using the following Unity function for the conversion to isometric coordinates.
Vector2 CartesianToIsometric(Vector2 cartPt){ Vector2 tempPt=new Vector2(); tempPt.x=cartPt.x-cartPt.y; tempPt.y=(cartPt.x+cartPt.y)/2; return (tempPt); }
Changes in Art
We will be using the same level information to create our 2D array, levelData
, which will drive the isometric representation. Most of the code will also remain the same, other than that specific to the isometric view.
The art, however, needs to have some changes with respect to the pivot points. Please refer to the image below and the explanation which follows.
The IsometricSokoban
game script uses modified sprites as heroSprite
, ballSprite
, and blockSprite
. The image shows the new pivot points used for these sprites. This change gives the pseudo 3D look we are aiming for with the isometric view. The blockSprite is a new sprite which we add when we find an invalidTile
.
It will help me explain the most important aspect of isometric games, depth sorting. Although the sprite is just a hexagon, we are considering it as a 3D cube where the pivot is situated at the middle of the bottom face of the cube.
Changes in Code
Please download the code shared through the linked git repository before proceeding further. The CreateLevel
method has a few changes which deal with the scale and positioning of the tiles and the addition of the blockTile
. The scale of the tileSprite
, which is just a diamond shape image representing our ground tile, needs to be altered as below.
tile.transform.localScale=new Vector2(tileSize-1,(tileSize-1)/2);//size is critical for isometric shape
This reflects the fact that an isometric tile will have a height of half of its width. The heroSprite
and the ballSprite
have a size of tileSize/2
.
hero.transform.localScale=Vector2.one*(tileSize/2);//we use half the tilesize for occupants
Wherever we find an invalidTile
, we add a blockTile
using the following code.
tile = new GameObject("block"+i.ToString()+"_"+j.ToString());//create new tile float rootThree=Mathf.Sqrt(3); float newDimension= 2*tileSize/rootThree; tile.transform.localScale=new Vector2(newDimension,tileSize);//we need to set some height sr = tile.AddComponent<SpriteRenderer>();//add a sprite renderer sr.sprite=blockSprite;//assign block sprite sr.sortingOrder=1;//this also need to have higher sorting order Color c= Color.gray; c.a=0.9f; sr.color=c; tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices occupants.Add(tile, new Vector2(i,j));//store the level indices of block in dict
The hexagon needs to be scaled differently to get the isometric look. This will not be an issue when the art is handled by artists. We are applying a slightly lower alpha value to the blockSprite
so that we can see through it, which enables us to see the depth sorting properly. Notice that we are adding these tiles to the occupants
dictionary as well, which will be used later for depth sorting.
The positioning of the tiles is done using the GetScreenPointFromLevelIndices
method, which in turn uses the CartesianToIsometric
conversion method explained earlier. The Y
axis points in the opposite direction for Unity, which needs to be considered while adding the middleOffset
to position the level in the middle of the screen.
Vector2 GetScreenPointFromLevelIndices(int row,int col){ //converting indices to position values, col determines x & row determine y Vector2 tempPt=CartesianToIsometric(new Vector2(col*tileSize/2,row*tileSize/2));//removed the '-' inthe y part as axis correction can happen after coversion tempPt.x-=middleOffset.x;//we apply the offset outside the coordinate conversion to align the level in screen middle tempPt.y*=-1;//unity y axis correction tempPt.y+=middleOffset.y;//we apply the offset outside the coordinate conversion to align the level in screen middle return tempPt; }
At the end of the CreateLevel
method as well as at the end of the TryMoveHero
method, we call the DepthSort
method. Depth sorting is the most important aspect of an isometric implementation. Essentially, we determine which tiles go behind or in front of other tiles in the level. The DepthSort
method is as shown below.
private void DepthSort() { int depth=1; SpriteRenderer sr; Vector2 pos=new Vector2(); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { int val=levelData[i,j]; if(val!=groundTile && val!=destinationTile){//a tile which needs depth sorting pos.x=i; pos.y=j; GameObject occupant=GetOccupantAtPosition(pos);//find the occupant at this position if(occupant==null)Debug.Log("no occupant"); sr=occupant.GetComponent<SpriteRenderer>(); sr.sortingOrder=depth;//assign new depth depth++;//increment depth } } } }
The beauty of a 2D array-based implementation is that for the proper isometric depth sorting, we just need to assign sequentially higher depth while we parse through the level in order, using sequential for loops. This works for our simple level with only a single layer of ground. If we had multiple ground levels at various heights, then the depth sorting could get complicated.
Everything else remains the same as the 2D implementation explained in the previous tutorial.
Completed Level
You can use the same keyboard controls to play the game. The only difference is that the hero will not be moving vertically or horizontally but isometrically. The finished level would look like the image below.
Check out how the depth sorting is clearly visible with our new blockTiles
.
That wasn't hard, was it? I invite you to change the level data in the text file to try out new levels. Next up is the hexagonal version, which is a bit more complicated, and I would advise you to take a break to play with the isometric version before proceeding.
2. Hexagonal Sokoban Game
The hexagonal version of the Sokoban level would look like the image below.
Hexagonal View
We are using the horizontal alignment for the hexagonal grid for this tutorial. The theory behind the hexagonal implementation requires a lot of further reading. Please refer to this tutorial series for a basic understanding. The theory is implemented in the helper class HexHelperHorizontal
, which can be found in the utils
folder.
Hexagonal Coordinate Conversion
The HexagonalSokoban
game script uses convenience methods from the helper class for coordinate conversions and other hexagonal features. The helper class HexHelperHorizontal
will only work with a horizontally aligned hexagonal grid. It includes methods to convert coordinates between offset, axial, and cubic systems.
The offset coordinate is the same 2D Cartesian coordinate. It also includes a getNeighbors
method, which takes in an axial coordinate and returns a List<Vector2>
with all the six neighbors of that cell coordinate. The order of the list is clockwise, starting with the northeast neighbor's cell coordinate.
Changes in Controls
With a hexagonal grid, we have six directions of motion instead of four, as the hexagon has six sides whereas a square has four. So we have six keyboard keys to control the movement of our hero, as shown in the image below.
The keys are arranged in the same layout as a hexagonal grid if you consider the keyboard key S
as the middle cell, with all the control keys as its hexagonal neighbors. It helps reduce the confusion with controlling the motion. The corresponding changes to the input code are as below.
private void ApplyUserInput() {//we have 6 directions of motion controlled by e,d,x,z,a,w in a cyclic sequence starting with NE to NW if(Input.GetKeyUp(userInputKeys[0])){ TryMoveHero(0);//north east }else if(Input.GetKeyUp(userInputKeys[1])){ TryMoveHero(1);//east }else if(Input.GetKeyUp(userInputKeys[2])){ TryMoveHero(2);//south east }else if(Input.GetKeyUp(userInputKeys[3])){ TryMoveHero(3);//south west }else if(Input.GetKeyUp(userInputKeys[4])){ TryMoveHero(4);//west }else if(Input.GetKeyUp(userInputKeys[5])){ TryMoveHero(5);//north west } }
There is no change in art, and there are no pivot changes necessary.
Other Changes in Code
I will be explaining the code changes with respect to the original 2D Sokoban tutorial and not the isometric version above. Please do refer to the linked source code for this tutorial. The most interesting fact is that almost all of the code remains the same. The CreateLevel
method has only one change, which is the middleOffset
calculation.
middleOffset.x=cols*tileWidth+tileWidth*0.5f;//this is changed for hexagonal middleOffset.y=rows*tileSize*3/4+tileSize*0.75f;//this is changed for isometric
One major change is obviously the way the screen coordinates are found in the GetScreenPointFromLevelIndices
method.
Vector2 GetScreenPointFromLevelIndices(int row,int col){ //converting indices to position values, col determines x & row determine y Vector2 tempPt=new Vector2(row,col); tempPt=HexHelperHorizontal.offsetToAxial(tempPt);//convert from offset to axial //convert axial point to screen point tempPt=HexHelperHorizontal.axialToScreen(tempPt,sideLength); tempPt.x-=middleOffset.x-Screen.width/2;//add offsets for middle align tempPt.y*=-1;//unity y axis correction tempPt.y+=middleOffset.y-Screen.height/2; return tempPt; }
Here we use the helper class to first convert the coordinate to axial and then find the corresponding screen coordinate. Please note the use of the sideLength
variable for the second conversion. It is the value of the length of a side of the hexagon tile, which is again equal to half of the distance between the two pointy ends of the hexagon. Hence:
sideLength=tileSize*0.5f;
The only other change is the GetNextPositionAlong
method, which is used by the TryMoveHero
method to find the next cell in a given direction. This method is completely changed to accommodate the entirely new layout of our grid.
private Vector2 GetNextPositionAlong(Vector2 objPos, int direction) {//this method is completely changed to accommodate the different way neighbours are found in hexagonal logic objPos=HexHelperHorizontal.offsetToAxial(objPos);//convert from offset to axial List<Vector2> neighbours= HexHelperHorizontal.getNeighbors(objPos); objPos=neighbours[direction];//the neighbour list follows the same order sequence objPos=HexHelperHorizontal.axialToOffset(objPos);//convert back from axial to offset return objPos; }
Using the helper class, we can easily return the coordinates of the neighbor in the given direction.
Everything else remains the same as the original 2D implementation. That wasn't hard, was it? That being said, understanding how we arrived at the conversion equations by following the hexagonal tutorial, which is the crux of the whole process, is not easy. If you play and complete the level, you will get the result as below.
Conclusion
The main element in both conversions was the coordinate conversions. The isometric version involves additional changes in the art with their pivot point as well as the need for depth sorting.
I believe you have found how easy it is to create grid-based games using just two-dimensional array-based level data and a tile-based approach. There are unlimited possibilities and games that you can create with this new understanding.
If you have understood all the concepts we have discussed so far, I would invite you to change the control method to tap and add some path finding. Good luck.