Advertisement
  1. Game Development
  2. Tile-Based Games

Introducción a las coordenadas axiales para juegos hexagonales basados en fichas

Scroll to top
Read Time: 14 min

() translation by (you can also view the original English article)

Final product imageFinal product imageFinal product image
What You'll Be Creating

El enfoque básico hexagonal basado en mosaico explicado en el tutorial de buscaminas hexagonal hace el trabajo pero no es muy eficiente. Utiliza la conversión directa de los datos de nivel basados en arreglos bidimensionales y las coordenadas de pantalla, lo que hace innecesariamente complicado determinar los mosaicos intervenidos.

Además, la necesidad de usar lógica diferente dependiendo de la fila o columna impar o pareja de un mosaico no es conveniente. Esta serie de tutoriales explora los sistemas de coordenadas de pantalla alternativos que podrían usarse para facilitar la lógica y hacer las cosas más convenientes. Le sugiero encarecidamente que lea el tutorial de Buscaminas hexagonal antes de seguir adelante con este tutorial, ya que explica la representación de la cuadrícula basada en una matriz bidimensional.

1. Coordenadas axiales

El enfoque predeterminado utilizado para las coordenadas de pantalla en el tutorial de buscaminas hexagonal se llama enfoque de coordenadas de desplazamiento. Esto se debe a que las filas o columnas alternativas se compensan con un valor mientras se alinea la cuadrícula hexagonal.

Para actualizar su memoria, consulte la imagen a continuación, que muestra la alineación horizontal con los valores de coordenadas de desplazamiento visualizados.

horizontally aligned hexagonal grid with offset coordinate valueshorizontally aligned hexagonal grid with offset coordinate valueshorizontally aligned hexagonal grid with offset coordinate values

En la imagen de arriba, una fila con el mismo valor i se resalta en rojo, y una columna con el mismo valor j se resalta en verde. Para simplificar todo, no discutiremos las variantes de compensación pares e impares ya que ambas son solo formas diferentes de obtener el mismo resultado.

Permítanme presentar una mejor alternativa de coordenadas de pantalla, la coordenada axial. La conversión de una coordenada de desplazamiento a una variante axial es muy simple. El valor i permanece igual, pero el valor j se convierte usando la fórmula axialJ = i - piso (j / 2). Se puede usar un método simple para convertir un offset Phaser.Point a su variante axial, como se muestra a continuación.

1
function offsetToAxial(offsetPoint){
2
    offsetPoint.y=(offsetPoint.y-(Math.floor(offsetPoint.x/2)));
3
    return offsetPoint;
4
}

La conversión inversa sería como se muestra a continuación.

1
function axialToOffset(axialPoint){
2
    axialPoint.y=(axialPoint.y+(Math.floor(axialPoint.x/2)));
3
    return axialPoint;
4
}

Aquí el valor x es el valor i, y el valor y es el valor j para la matriz bidimensional. Después de la conversión, los nuevos valores se verían como la imagen de abajo.

horizontally aligned hexagonal grid with axial coordinate valueshorizontally aligned hexagonal grid with axial coordinate valueshorizontally aligned hexagonal grid with axial coordinate values

Observe que la línea verde donde el valor de j permanece igual ya no hace zigzag, sino que ahora es una diagonal de nuestra grilla hexagonal.

Para la cuadrícula hexagonal alineada verticalmente, las coordenadas de desplazamiento se muestran en la imagen a continuación.

vertically aligned hexagonal grid with offset coordinate valuesvertically aligned hexagonal grid with offset coordinate valuesvertically aligned hexagonal grid with offset coordinate values

La conversión a coordenadas axiales sigue las mismas ecuaciones, con la diferencia de que mantenemos el valor j igual y alteramos el valor i. El siguiente método muestra la conversión.

1
function offsetToAxial(offsetPoint){
2
    offsetPoint.x=(offsetPoint.x-(Math.floor(offsetPoint.y/2)));
3
    return offsetPoint;
4
}

El resultado es como se muestra a continuación.

vertically aligned hexagonal grid with axial coordinate valuesvertically aligned hexagonal grid with axial coordinate valuesvertically aligned hexagonal grid with axial coordinate values

Antes de utilizar las nuevas coordenadas para resolver problemas, permítanme presentarles rápidamente otra alternativa de coordenadas de pantalla: coordenadas de cubo.

2. Cubo o Coordenadas cúbicas

Enderezar el zigzag en sí mismo ha solucionado la mayoría de los inconvenientes que tuvimos con el sistema de coordenadas desplazadas. Las coordenadas cubo o cúbicas nos ayudarían a simplificar la lógica complicada como la heurística o al girar alrededor de una celda hexagonal.

Como puede haber adivinado por el nombre, el sistema cúbico tiene tres valores. El tercer valor de k o z se deriva de la ecuación x + y + z = 0, donde x e y son las coordenadas axiales. Esto nos lleva a este método simple para calcular el valor de z.

1
function calculateCubicZ(axialPoint){
2
    return -axialPoint.x-axialPoint.y;
3
}

La ecuación x + y + z = 0 es en realidad un plano 3D que pasa a través de la diagonal de una cuadrícula de cubo tridimensional. Mostrar los tres valores para la grilla dará como resultado las siguientes imágenes para las diferentes alineaciones hexagonales.

horizontally aligned hexagonal grid with cube coordinate valueshorizontally aligned hexagonal grid with cube coordinate valueshorizontally aligned hexagonal grid with cube coordinate values
vertically aligned hexagonal grid with cube coordinate valuesvertically aligned hexagonal grid with cube coordinate valuesvertically aligned hexagonal grid with cube coordinate values

La línea azul indica las fichas donde el valor de z permanece igual.

3. Ventajas del nuevo sistema de coordenadas

Tal vez se pregunte cómo estos nuevos sistemas de coordenadas nos ayudan con la lógica hexagonal. Explicaré algunos beneficios antes de pasar a crear un Tetris hexagonal utilizando nuestro nuevo conocimiento.

Movimiento

Consideremos el mosaico medio en la imagen de arriba, que tiene valores de coordenadas cúbicas de 3,6, -9. Hemos notado que un valor de coordenadas permanece igual para las fichas en las líneas de color. Además, podemos ver que las coordenadas restantes aumentan o disminuyen en 1 mientras rastrean cualquiera de las líneas de color. Por ejemplo, si el valor x permanece igual y el valor y aumenta en 1 a lo largo de una dirección, el valor z disminuye en 1 para satisfacer nuestra ecuación que rige x + y + z = 0. Esta característica hace que controlar el movimiento sea mucho más fácil. Pondremos esto en uso en la segunda parte de la serie.

Vecinos

Por la misma lógica, es sencillo encontrar los vecinos para el mosaico x, y, z. Al mantener x lo mismo, obtenemos dos vecinos diagonales, x, y-1, z + 1 y x, y + 1, z-1. Al mantener y lo mismo, obtenemos dos vecinos verticales, x-1, y, z + 1 y x + 1, y, z-1. Al mantener z igual, obtenemos los dos vecinos diagonales restantes, x + 1, y-1, z y x-1, y + 1, z. La imagen a continuación ilustra esto para un mosaico en el origen.

the cube coordinates of neighbours of a hexagonal tile at originthe cube coordinates of neighbours of a hexagonal tile at originthe cube coordinates of neighbours of a hexagonal tile at origin

Es mucho más fácil ahora que no necesitamos usar una lógica diferente basada en filas / columnas pares o impares.

Desplazarse por una ficha

Una cosa interesante de notar en la imagen de arriba es un tipo de simetría cíclica para todas las fichas alrededor de la ficha roja. Si tomamos las coordenadas de cualquier ficha contigua, las coordenadas de la ficha contigua inmediata pueden obtenerse ciclando los valores de las coordenadas ya sea hacia la izquierda o hacia la derecha y luego multiplicando por -1.

Por ejemplo, el vecino superior tiene un valor de -1,0,1, que al girar a la derecha una vez se convierte en 1, -1,0 y después de multiplicar por -1 se convierte en -1,1,0, que es la coordenada del vecino derecho. Girar a la izquierda y multiplicar por -1 produce 0, -1,1, que es la coordenada del vecino izquierdo. Repitiendo esto, podemos saltar entre todas las fichas vecinas alrededor del mosaico central. Esta es una característica muy interesante que podría ayudar a la lógica y los algoritmos.

Tenga en cuenta que esto está sucediendo solo debido al hecho de que se considera que el nivel medio está en el origen. Podríamos hacer fácilmente que cualquier mosaico x, y, z esté en el origen restando los valores x, y y z de él y de todos los demás mosaicos.

Heurísticas

Calcular heurística eficiente es clave cuando se trata de pathfinding o algoritmos similares. Las coordenadas cúbicas facilitan la búsqueda de heurísticas simples para cuadrículas hexagonales debido a los aspectos mencionados anteriormente. Discutiremos esto en detalle en la segunda parte de esta serie.

Estas son algunas de las ventajas del nuevo sistema de coordenadas. Podríamos usar una combinación de los diferentes sistemas de coordenadas en nuestras implementaciones prácticas. Por ejemplo, la matriz bidimensional sigue siendo la mejor manera de guardar los datos de nivel, cuyas coordenadas son las coordenadas de desplazamiento.

Intentemos crear una versión hexagonal del famoso juego Tetris usando este nuevo conocimiento.

4. Creando un Tetris Hexagonal

Todos hemos jugado Tetris, y si usted es un desarrollador de juegos, es posible que también haya creado su propia versión. Tetris es uno de los juegos basados en fichas más fáciles que uno puede implementar, aparte de tres en raya o damas, usando una matriz bidimensional simple. Primero enumeremos las características de Tetris.

  • Comienza con una cuadrícula bidimensional en blanco.
  • Diferentes bloques aparecen en la parte superior y se mueven hacia abajo una ficha a la vez hasta que llegan al final.
  • Una vez que llegan al fondo, se cementan allí o se vuelven no interactivos. Básicamente, se vuelven parte de la grilla.
  • Mientras se deja caer, el bloque se puede mover hacia los lados, rotar en el sentido de las agujas del reloj / en el sentido contrario a las agujas del reloj y soltarlo.
  • El objetivo es rellenar todas las fichas en cualquier fila, sobre la cual desaparece toda la fila, colapsando el resto de la grilla llena sobre ella.
  • El juego termina cuando no hay más fichas libres en la parte superior para que un nuevo bloque ingrese a la grilla.

Representando los diferentes bloques

Como el juego tiene bloques que caen verticalmente, utilizaremos una cuadrícula hexagonal alineada verticalmente. Esto significa que moverlos hacia los lados hará que se muevan en zigzag. Una fila completa en la cuadrícula consiste en un conjunto de fichas en orden de zigzag. A partir de este momento, puede comenzar a consultar el código fuente proporcionado junto con este tutorial.

Los datos de nivel se almacenan en una matriz bidimensional llamada levelData, y la representación se realiza utilizando las coordenadas de desplazamiento, tal como se explica en el tutorial hexagonal de buscaminas. Consulte si tiene dificultades para seguir el código.

El elemento interactivo en la siguiente sección muestra los diferentes bloques que vamos a usar. Hay un bloque adicional más, que consiste en tres fichas rellenas alineadas verticalmente como un pilar. BlockData se usa para crear los diferentes bloques.

1
function BlockData(topB,topRightB,bottomRightB,bottomB,bottomLeftB,topLeftB){
2
    this.tBlock=topB;
3
    this.trBlock=topRightB;
4
    this.brBlock=bottomRightB;
5
    this.bBlock=bottomB;
6
    this.blBlock=bottomLeftB;
7
    this.tlBlock=topLeftB;
8
    this.mBlock=1;
9
}

Una plantilla de bloque en blanco es un conjunto de siete fichas que consisten en un mosaico medio rodeado por sus seis vecinos. Para cualquier bloque de Tetris, el cuadro medio siempre se llena con un valor de 1, mientras que un mosaico vacío se denota con un valor de 0. Los diferentes bloques se crean rellenando las fichas de BlockData como se muestra a continuación.

1
var block1= new BlockData(1,1,0,0,0,1);
2
var block2= new BlockData(0,1,0,0,0,1);
3
var block3= new BlockData(1,1,0,0,0,0);
4
var block4= new BlockData(1,1,0,1,0,0);
5
var block5= new BlockData(1,0,0,1,0,1);
6
var block6= new BlockData(0,1,1,0,1,1);
7
var block7= new BlockData(1,0,0,1,0,0);

Tenemos un total de siete bloques diferentes.

Rotación de los bloques

Déjame mostrarte cómo giran los bloques usando el elemento interactivo a continuación. Toque y mantenga para girar los bloques y toque x para cambiar la dirección de rotación.

Para rotar el bloque, necesitamos encontrar todos los mosaicos que tengan un valor de 1, establecer el valor en 0, rotar una vez alrededor del azulejo del medio para encontrar el mosaico vecino y establecer su valor en 1. Para rotar un mosaico alrededor de otro mosaico, podemos usar la lógica explicada en el movimiento alrededor de una sección de mosaico de arriba. Llegamos al siguiente método para este propósito.

1
function rotateTileAroundTile(tileToRotate, anchorTile){
2
    tileToRotate=offsetToAxial(tileToRotate);//convert to axial

3
    var tileToRotateZ=calculateCubicZ(tileToRotate);//find z value

4
    anchorTile=offsetToAxial(anchorTile);//convert to axial

5
    var anchorTileZ=calculateCubicZ(anchorTile);//find z value

6
    tileToRotate.x=tileToRotate.x-anchorTile.x;//find x difference

7
    tileToRotate.y=tileToRotate.y-anchorTile.y;//find y difference

8
    tileToRotateZ=tileToRotateZ-anchorTileZ;//find z difference

9
    var pointArr=[tileToRotate.x,tileToRotate.y,tileToRotateZ];//populate array to rotate

10
    pointArr=arrayRotate(pointArr,clockWise);//rotate array, true for clockwise

11
    tileToRotate.x=(-1*pointArr[0])+anchorTile.x;//multiply by -1 & remove the x difference

12
    tileToRotate.y=(-1*pointArr[1])+anchorTile.y;//multiply by -1 & remove the y difference

13
    tileToRotate=axialToOffset(tileToRotate);//convert to offset

14
    return tileToRotate;
15
}
16
//...

17
function arrayRotate(arr, reverse){//nifty method to rotate array elements

18
  if(reverse)
19
    arr.unshift(arr.pop())
20
  else
21
    arr.push(arr.shift())
22
  return arr
23
} 

La variable clockWise se usa para girar en sentido horario o antihorario, lo que se logra moviendo los valores de la matriz en direcciones opuestas en arrayRotate.

Mover el bloque

Realizamos un seguimiento de las coordenadas de desplazamiento i y j para la casilla media del bloque utilizando las variables blockMidRowValue y blockMidColumnValue, respectivamente. Para mover el bloque, incrementamos o disminuimos estos valores. Actualizamos los valores correspondientes en levelData con los valores de bloque utilizando el método paintBlock. El levelData actualizado se utiliza para representar la escena después de cada cambio de estado.

1
var blockMidRowValue;
2
var blockMidColumnValue;
3
//...

4
function moveLeft(){
5
    blockMidColumnValue--;
6
}
7
function moveRight(){
8
    blockMidColumnValue++;
9
}
10
function dropDown(){
11
    paintBlock(true);
12
    blockMidRowValue++;
13
}
14
function paintBlock(){
15
    clockWise=true;
16
    var val=1;
17
    changeLevelData(blockMidRowValue,blockMidColumnValue,val);
18
    var rotatingTile=new Phaser.Point(blockMidRowValue-1,blockMidColumnValue);
19
    if(currentBlock.tBlock==1){
20
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.tBlock);
21
    }
22
    var midPoint=new Phaser.Point(blockMidRowValue,blockMidColumnValue);
23
    rotatingTile=rotateTileAroundTile(rotatingTile,midPoint);
24
    if(currentBlock.trBlock==1){
25
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.trBlock);
26
    }
27
    midPoint.x=blockMidRowValue;
28
    midPoint.y=blockMidColumnValue;
29
    rotatingTile=rotateTileAroundTile(rotatingTile,midPoint);
30
    if(currentBlock.brBlock==1){
31
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.brBlock);
32
    }
33
    midPoint.x=blockMidRowValue;
34
    midPoint.y=blockMidColumnValue;
35
    rotatingTile=rotateTileAroundTile(rotatingTile,midPoint);
36
    if(currentBlock.bBlock==1){
37
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.bBlock);
38
    }
39
    midPoint.x=blockMidRowValue;
40
    midPoint.y=blockMidColumnValue;
41
    rotatingTile=rotateTileAroundTile(rotatingTile,midPoint);
42
    if(currentBlock.blBlock==1){
43
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.blBlock);
44
    }
45
    midPoint.x=blockMidRowValue;
46
    midPoint.y=blockMidColumnValue;
47
    rotatingTile=rotateTileAroundTile(rotatingTile,midPoint);
48
    if(currentBlock.tlBlock==1){
49
        changeLevelData(rotatingTile.x,rotatingTile.y,val*currentBlock.tlBlock);
50
    }
51
}
52
function changeLevelData(iVal,jVal,newValue,erase){
53
    if(!validIndexes(iVal,jVal))return;
54
    if(erase){
55
        if(levelData[iVal][jVal]==1){
56
            levelData[iVal][jVal]=0;
57
        }
58
    }else{
59
        levelData[iVal][jVal]=newValue;
60
    }
61
}
62
function validIndexes(iVal,jVal){
63
    if(iVal<0 || jVal<0 || iVal>=levelData.length || jVal>=levelData[0].length){
64
        return false;
65
    }
66
    return true;
67
}

Aquí, currentBlock apunta a blockData en la escena. En paintBlock, primero establecemos el valor levelData para el mosaico medio del bloque en 1, ya que siempre es 1 para todos los bloques. El índice del punto medio es blockMidRowValue, blockMidColumnValue.

Luego nos movemos al índice levelData del mosaico en la parte superior del  mosaico medio blockMidRowValue-1, blockMidColumnValue, y lo establecemos en 1 si el bloque tiene este mosaico como 1. Luego rotamos en el sentido de las agujas del reloj una vez alrededor del mosaico del medio para obtener el siguiente mosaico y repetir el mismo proceso. Esto se hace para todas las fichas alrededor del mosaico medio del bloque.

Verificación de operaciones válidas

Al mover o girar el bloque, debemos verificar si esa es una operación válida. Por ejemplo, no podemos mover o rotar el bloque si las teselas que necesita ocupar ya están ocupadas. Además, no podemos mover el bloque fuera de nuestra grilla bidimensional. También debemos verificar si el bloque puede caer más, lo que determinaría si tenemos que cementar el bloque o no.

Para todos estos, utilizo un método canMove (i, j), que devuelve un booleano que indica si colocar el bloque en i, j es un movimiento válido. Para cada operación, antes de realmente cambiar los valores de levelData, verificamos si la nueva posición para el bloque es una posición válida usando este método.

1
function canMove(iVal,jVal){
2
    var validMove=true;
3
    
4
    var store=clockWise;
5
    var newBlockMidPoint=new Phaser.Point(blockMidRowValue+iVal,blockMidColumnValue+jVal);
6
    clockWise=true;
7
    if(!validAndEmpty(newBlockMidPoint.x,newBlockMidPoint.y)){//check mid, always 1

8
        validMove=false;
9
    }
10
    var rotatingTile=new Phaser.Point(newBlockMidPoint.x-1,newBlockMidPoint.y);
11
    if(currentBlock.tBlock==1){
12
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){//check top

13
            validMove=false;
14
        }
15
    }
16
    newBlockMidPoint.x=blockMidRowValue+iVal;
17
    newBlockMidPoint.y=blockMidColumnValue+jVal;
18
    rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint);
19
    if(currentBlock.trBlock==1){
20
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){
21
            validMove=false;
22
        }
23
    }
24
    newBlockMidPoint.x=blockMidRowValue+iVal;
25
    newBlockMidPoint.y=blockMidColumnValue+jVal;
26
    rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint);
27
    if(currentBlock.brBlock==1){
28
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){
29
            validMove=false;
30
        }
31
    }
32
    newBlockMidPoint.x=blockMidRowValue+iVal;
33
    newBlockMidPoint.y=blockMidColumnValue+jVal;
34
    rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint);
35
    if(currentBlock.bBlock==1){
36
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){
37
            validMove=false;
38
        }
39
    }
40
    newBlockMidPoint.x=blockMidRowValue+iVal;
41
    newBlockMidPoint.y=blockMidColumnValue+jVal;
42
    rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint);
43
    if(currentBlock.blBlock==1){
44
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){
45
            validMove=false;
46
        }
47
    }
48
    newBlockMidPoint.x=blockMidRowValue+iVal;
49
    newBlockMidPoint.y=blockMidColumnValue+jVal;
50
    rotatingTile=rotateTileAroundTile(rotatingTile,newBlockMidPoint);
51
    if(currentBlock.tlBlock==1){
52
        if(!validAndEmpty(rotatingTile.x,rotatingTile.y)){
53
            validMove=false;
54
        }
55
    }
56
    clockWise=store;
57
    return validMove;
58
}
59
function validAndEmpty(iVal,jVal){
60
    if(!validIndexes(iVal,jVal)){
61
        return false;
62
    }else if(levelData[iVal][jVal]>1){//occuppied

63
        return false;
64
    }
65
    return true;
66
}

El proceso aquí es el mismo que paintBlock, pero en lugar de alterar cualquier valor, esto simplemente devuelve un valor booleano que indica un movimiento válido. Aunque estoy usando la rotación alrededor de una lógica de mosaico medio para encontrar los vecinos, la alternativa más fácil y bastante eficiente es usar los valores de coordenadas directas de los vecinos, que se pueden determinar fácilmente a partir de las coordenadas del mosaico medio.

Renderizando el juego

El nivel del juego está visualmente representado por una RenderTexture llamada gameScene. En el conjunto levelData, un mosaico desocupado tendría un valor de 0 y un mosaico ocupado tendría un valor de 2 o superior.

Un bloque cementado se denota con un valor de 2, y un valor de 5 denota un mosaico que debe eliminarse, ya que forma parte de una fila completa. Un valor de 1 significa que el mosaico es parte del bloque. Después de cada cambio de estado del juego, renderizamos el nivel usando la información en levelData, como se muestra a continuación.

1
//..

2
hexSprite.tint='0xffffff';
3
if(levelData[i][j]>-1){
4
    axialPoint=offsetToAxial(axialPoint);
5
    cubicZ=calculateCubicZ(axialPoint);
6
    if(levelData[i][j]==1){
7
        hexSprite.tint='0xff0000';
8
    }else if(levelData[i][j]==2){
9
        hexSprite.tint='0x0000ff';
10
    }else if(levelData[i][j]>2){
11
        hexSprite.tint='0x00ff00';
12
    }
13
    gameScene.renderXY(hexSprite,startX, startY, false);
14
}
15
//...

Por lo tanto, un valor de 0 se representa sin ningún tinte, un valor de 1 se representa con tinte rojo, un valor de 2 se representa con tinte azul y un valor de 5 se representa con tinte verde.

5. El juego completo

Juntando todo, obtenemos el juego Tetris hexagonal completo. Consulte el código fuente para comprender la implementación completa. Notarás que estamos usando coordenadas de desplazamiento y coordenadas cúbicas para diferentes propósitos. Por ejemplo, para encontrar si se completa una fila, hacemos uso de las coordenadas de desplazamiento y verificamos las filas de levelData.

Conclusión

Esto concluye la primera parte de la serie. Hemos creado con éxito un juego de Tetris hexagonal utilizando una combinación de coordenadas de desplazamiento, coordenadas axiales y coordenadas de cubo.

En la parte final de la serie, aprenderemos sobre el movimiento del personaje usando las nuevas coordenadas en una cuadrícula hexagonal alineada horizontalmente.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.