Unidad Azulejo 2D 'Sokoban' Juego
Spanish (Español) translation by James (you can also view the original English article)



En este tutorial exploraremos un enfoque para crear un sokoban o empujador de cajas usando lógica basada en azulejo y una matriz bidimensional para datos de nivel del juego. Estamos utilizando la unidad para el desarrollo con C# como lenguaje. Por favor descargar los archivos fuente con este tutorial a seguir.
1. El Juego de Sokoban
Puede haber algunos que no han jugado una variante del juego Sokoban. La versión original puede ser incluso mayor que algunos de ustedes. Por favor revise la página wiki de algunos detalles. Esencialmente, tenemos un carácter o elemento controlados por el usuario que tiene que empujar cajas o elementos similares en su mosaico de destino.
El nivel consiste en una rejilla cuadrada o rectangular de azulejos donde un azulejo puede ser uno no transitable o caminar. Podemos caminar sobre las tejas caminables y empujar las cajas sobre ellos. Especiales azulejos transitables estaría marcados como azulejos del destino, que es donde el cajón debe descansar finalmente para completar el nivel. El carácter generalmente se controla mediante un teclado. Una vez que todos los cajones de acceder a una ficha de destino, el nivel es completo.
Desarrollo basado en el azulejo esencialmente significa que nuestro juego está compuesta por un número de fichas en forma predeterminada. Un elemento de datos de nivel representa cómo los azulejos tendrían que ser repartidos para crear nuestro nivel. En nuestro caso, usaremos una mosaico basado en cuadrícula. Puede leer más sobre juegos basados en azulejo aquí en Envato Tuts+.
2. Preparar el Proyecto Unity
Vamos a ver cómo nos hemos organizado nuestro proyecto de unidad para este tutorial.
El Arte
Para este proyecto tutorial, no está utilizando los activos externos del arte, pero utiliza a las primitivas de sprite creadas con la versión 2017.1 la última unidad. La imagen de abajo muestra cómo nosotros podemos crear sprites en forma diferentes dentro de la unidad.



Usaremos el sprite Cuadrado para representar una sola baldosa en nuestra red de nivel de sokoban. Utilizaremos el sprite del Triángulo para representar a nuestro personaje, y se utilice el sprite de Círculo para representar un cajón, o en este caso una bola. Los azulejos de suelo normal son blancos, mientras que los azulejos de destino tienen un color diferente para destacar.
Los Datos a Nivel
Nos representarán nuestros datos en forma de una matriz bidimensional que proporciona la perfecta correlación entre la lógica y los elementos visuales. Utilizamos un archivo de texto simple para almacenar los datos, que hace más fácil para nosotros editar el nivel fuera de la unidad o cambiar niveles simplemente cambiando los archivos cargados. La carpeta de Recursos tiene un archivo de texto de nivel que tiene nuestro nivel por defecto.
1 |
1,1,1,1,1,1,1 |
2 |
1,3,1,-1,1,0,1 |
3 |
-1,0,1,2,1,1,-1 |
4 |
1,1,1,3,1,3,1 |
5 |
1,1,0,-1,1,1,1 |
El nivel tiene siete columnas y cinco filas. Un valor de 1 significa que tenemos un suelo de azulejos en esa posición. Un valor de -1 significa que es no transitable del azulejo, mientras que un valor de 0 significa que él es un destino. El valor 2 representa a nuestro héroe, y 3 representa una bola pushable. Mirando los datos, podemos visualizar cómo sería nuestro nivel.
3. Crear un Juego de Sokoban Nivel
Para mantener las cosas simples y que no es una lógica muy complicada, tenemos solamente un solo Sokoban.cs archivo de comandos para el proyecto, y se une a la cámara de la escena. Por favor manténgalo abierto en el editor mientras sigues el resto del tutorial.
Datos de Nivel Especiales
Los datos representados por la matriz 2D se utilizan no sólo para crear la cuadrícula inicial pero también se utilizan en todo el juego de cambios de nivel y progreso. Esto significa que los valores actuales no son suficientes para representar algunos de los Estados de nivel durante el juego.
Cada valor representa el estado de la pieza correspondiente en el nivel. Necesitamos valores adicionales para representar una bola en el mosaico de destino y el héroe en el mosaico de destino, que son respectivamente -2 y -3. Estos valores pueden ser cualquier valor que se asigna en el guión del juego, no necesariamente los mismos valores que hemos usado aquí.
Análisis del Archivo de Texto de Nivel
El primer paso es cargar los datos en un array 2D desde el archivo de texto externo. Utilizamos el método ParseLevel para cargar el valor de string y dividirlo para rellenar nuestro array 2D de leveldata.
1 |
void ParseLevel(){ |
2 |
TextAsset textFile = Resources.Load (levelName) as TextAsset; |
3 |
string[] lines = textFile.text.Split (new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries);//split by new line, return |
4 |
string[] nums = lines[0].Split(new[] { ',' });//split by , |
5 |
rows=lines.Length;//number of rows |
6 |
cols=nums.Length;//number of columns |
7 |
levelData = new int[rows, cols]; |
8 |
for (int i = 0; i < rows; i++) { |
9 |
string st = lines[i]; |
10 |
nums = st.Split(new[] { ',' }); |
11 |
for (int j = 0; j < cols; j++) { |
12 |
int val; |
13 |
if (int.TryParse (nums[j], out val)){ |
14 |
levelData[i,j] = val; |
15 |
}
|
16 |
else{ |
17 |
levelData[i,j] = invalidTile; |
18 |
}
|
19 |
}
|
20 |
}
|
21 |
}
|
Al analizar, determinamos el número de filas y columnas de que nuestro nivel tiene rellenar nuestro levelData.
Nivel de Dibujo
Una vez tengamos nuestros datos, podemos sacar nuestro nivel en la pantalla. Utilizamos el método CreateLevel para hacer justamente eso.
1 |
void CreateLevel(){ |
2 |
//calculate the offset to align whole level to scene middle
|
3 |
middleOffset.x=cols*tileSize*0.5f-tileSize*0.5f; |
4 |
middleOffset.y=rows*tileSize*0.5f-tileSize*0.5f;; |
5 |
GameObject tile; |
6 |
SpriteRenderer sr; |
7 |
GameObject ball; |
8 |
int destinationCount=0; |
9 |
for (int i = 0; i < rows; i++) { |
10 |
for (int j = 0; j < cols; j++) { |
11 |
int val=levelData[i,j]; |
12 |
if(val!=invalidTile){//a valid tile |
13 |
tile = new GameObject("tile"+i.ToString()+"_"+j.ToString());//create new tile |
14 |
tile.transform.localScale=Vector2.one*(tileSize-1);//set tile size |
15 |
sr = tile.AddComponent<SpriteRenderer>();//add a sprite renderer |
16 |
sr.sprite=tileSprite;//assign tile sprite |
17 |
tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices |
18 |
if(val==destinationTile){//if it is a destination tile, give different color |
19 |
sr.color=destinationColor; |
20 |
destinationCount++;//count destinations |
21 |
}else{ |
22 |
if(val==heroTile){//the hero tile |
23 |
hero = new GameObject("hero"); |
24 |
hero.transform.localScale=Vector2.one*(tileSize-1); |
25 |
sr = hero.AddComponent<SpriteRenderer>(); |
26 |
sr.sprite=heroSprite; |
27 |
sr.sortingOrder=1;//hero needs to be over the ground tile |
28 |
sr.color=Color.red; |
29 |
hero.transform.position=GetScreenPointFromLevelIndices(i,j); |
30 |
occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict |
31 |
}else if(val==ballTile){//ball tile |
32 |
ballCount++;//increment number of balls in level |
33 |
ball = new GameObject("ball"+ballCount.ToString()); |
34 |
ball.transform.localScale=Vector2.one*(tileSize-1); |
35 |
sr = ball.AddComponent<SpriteRenderer>(); |
36 |
sr.sprite=ballSprite; |
37 |
sr.sortingOrder=1;//ball needs to be over the ground tile |
38 |
sr.color=Color.black; |
39 |
ball.transform.position=GetScreenPointFromLevelIndices(i,j); |
40 |
occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict |
41 |
}
|
42 |
}
|
43 |
}
|
44 |
}
|
45 |
}
|
46 |
if(ballCount>destinationCount)Debug.LogError("there are more balls than destinations"); |
47 |
}
|
Para nuestro nivel, hemos creado un valor tileSize de 50, que es la longitud del lado de un azulejo cuadrado en nuestra cuadrícula de nivel. Bucle a través de nuestra matriz 2D y determinar que el valor almacenado en cada uno de los i y j los índices de la matriz. Si este valor no es una invalidTile (-1) creamos un nuevo GameObject llamado tile. Atribuimos un componente SpriteRenderer para tile y asignar el Sprite correspondiente o el Color dependiendo del valor en el índice de matriz.
Colocando el hero o la ball, tenemos que primero crear un azulejo de la tierra y crear a continuación estos azulejos. Como el héroe y la bola que deba ser superponer el azulejo de suelo, le damos su SpriteRenderer un mayor sortingOrder. Todas las fichas se asignan un localscale de tileSize así que son 50x50 en nuestra escena.
Mantener seguimiento del número de bolas en nuestra escena utilizando la variable ballCount, y debe existir el mismo o un mayor número de fichas de destino en nuestro nivel para hacer posible la finalización del nivel. La magia sucede en una sola línea de código donde determinamos la posición de cada mosaico que utiliza el método GetScreenPointFromLevelIndices (int row, int col).
1 |
//...
|
2 |
tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices |
3 |
//...
|
4 |
|
5 |
Vector2 GetScreenPointFromLevelIndices(int row,int col){ |
6 |
//converting indices to position values, col determines x & row determine y
|
7 |
return new Vector2(col*tileSize-middleOffset.x,row*-tileSize+middleOffset.y); |
8 |
}
|
La posición mundial de un azulejo se determina multiplicando los índices de nivel con el valor de tileSize. La variable middleOffset se utiliza para alinear el nivel en el centro de la pantalla. Observe que el valor de la fila se multiplica por un valor negativo para apoyar el eje de la y invertida en la unidad.
4. Sokoban Lógica
Ahora que hemos mostrado nuestro nivel, vamos a proceder a la lógica del juego. Tenemos que escuchar para entrada de tecla del usuario y mover al héroe basado en la entrada. La tecla determina una dirección correcta del movimiento, y el hero tiene que moverse en esa dirección. Hay varios escenarios a tener en cuenta una vez que hemos determinado la necesaria dirección del movimiento. Digamos que el azulejo junto al héroe en esta dirección es tileK.
- ¿Hay un azulejo en la escena en esa posición, o está fuera de nuestra red?
- ¿Es tileK un azulejo transitable?
- ¿TileK ocupa una bola?
Si la posición de tileK está fuera de la red, lo no hacemos necesidad de hacer nada. Si tileK es válido y es transitable, necesitamos pasar de héroe a esa posición y actualizar nuestra levelData. Si tileK tiene una bola, entonces debemos de considerar al siguiente vecino en la misma dirección, digamos tileL.
- ¿Es tileL fuera de la red?
- ¿Es tileL un azulejo transitable?
- ¿TileL ocupa una ball?
Sólo en el caso donde tileL es un azulejo transitable, no ocupado debemos movemos el hero y la bola a tileK tileK y tileL respectivamente. Después de movimiento exitoso, debemos actualizar la matriz de leveldata.
Funciones de Apoyo
La lógica anterior significa que tenemos que saber que nuestro héroe está actualmente en el azulejo. También debemos determinar si un cierto azulejo tiene un balón y debe tener acceso a esa bola.
Para facilitar esto, usamos un diccionario llamado a ocupantes que almacena un GameObject como clave y sus índices de array como Vector2 como valor. En el método CreateLevel, nosotros pueblan a los ocupantes cuando creamos hero o ball. Una vez que tenemos el Diccionario poblado, podemos utilizar el GetOccupantAtPosition para volver el GameObject en un índice de matriz determinado.
1 |
Dictionary<GameObject,Vector2> occupants;//reference to balls & hero |
2 |
|
3 |
//..
|
4 |
occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict |
5 |
//..
|
6 |
occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict |
7 |
//..
|
8 |
|
9 |
private GameObject GetOccupantAtPosition(Vector2 heroPos) |
10 |
{//loop through the occupants to find the ball at given position |
11 |
GameObject ball; |
12 |
foreach (KeyValuePair<GameObject, Vector2> pair in occupants) |
13 |
{
|
14 |
if (pair.Value == heroPos) |
15 |
{
|
16 |
ball = pair.Key; |
17 |
return ball; |
18 |
}
|
19 |
}
|
20 |
return null; |
21 |
}
|
El método IsOccupied determina si el valor de levelData en los índices siempre representa una bola.
1 |
private bool IsOccupied(Vector2 objPos) |
2 |
{//check if there is a ball at given array position |
3 |
return (levelData[(int)objPos.x,(int)objPos.y]==ballTile || levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile); |
4 |
}
|
También necesitamos una manera de comprobar si una determinada posición está dentro de nuestra red y si azulejo es transitable. El método IsValidPosition comprueba los índices nivel pasados como parámetros para determinar si cae dentro de nuestras dimensiones nivel. Comprueba también si tenemos un invalidTile como ese índice en el levelData.
1 |
private bool IsValidPosition(Vector2 objPos) |
2 |
{//check if the given indices fall within the array dimensions |
3 |
if(objPos.x>-1&&objPos.x<rows&&objPos.y>-1&&objPos.y<cols){ |
4 |
return levelData[(int)objPos.x,(int)objPos.y]!=invalidTile; |
5 |
}else return false; |
6 |
}
|
Responder a la Entrada del Usuario
En el método de update de nuestro script juego, revise los eventos KeyUp del usuario y comparar contra nuestras entradas claves almacenadas en la matriz userInputKeys. Una vez determinada la dirección necesaria del movimiento, llamamos al método TryMoveHero con la dirección como parámetro.
1 |
void Update(){ |
2 |
if(gameOver)return; |
3 |
ApplyUserInput();//check & use user input to move hero and balls |
4 |
}
|
5 |
|
6 |
private void ApplyUserInput() |
7 |
{
|
8 |
if(Input.GetKeyUp(userInputKeys[0])){ |
9 |
TryMoveHero(0);//up |
10 |
}else if(Input.GetKeyUp(userInputKeys[1])){ |
11 |
TryMoveHero(1);//right |
12 |
}else if(Input.GetKeyUp(userInputKeys[2])){ |
13 |
TryMoveHero(2);//down |
14 |
}else if(Input.GetKeyUp(userInputKeys[3])){ |
15 |
TryMoveHero(3);//left |
16 |
}
|
17 |
}
|
El método de TryMoveHero es donde se implementa nuestra lógica juego core explicada al comienzo de esta sección. Por favor revise el siguiente método cuidadosamente para ver cómo se implementa la lógica como se explicó anteriormente.
1 |
private void TryMoveHero(int direction) |
2 |
{
|
3 |
Vector2 heroPos; |
4 |
Vector2 oldHeroPos; |
5 |
Vector2 nextPos; |
6 |
occupants.TryGetValue(hero,out oldHeroPos); |
7 |
heroPos=GetNextPositionAlong(oldHeroPos,direction);//find the next array position in given direction |
8 |
|
9 |
if(IsValidPosition(heroPos)){//check if it is a valid position & falls inside the level array |
10 |
if(!IsOccupied(heroPos)){//check if it is occupied by a ball |
11 |
//move hero
|
12 |
RemoveOccupant(oldHeroPos);//reset old level data at old position |
13 |
hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y); |
14 |
occupants[hero]=heroPos; |
15 |
if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){//moving onto a ground tile |
16 |
levelData[(int)heroPos.x,(int)heroPos.y]=heroTile; |
17 |
}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){//moving onto a destination tile |
18 |
levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile; |
19 |
}
|
20 |
}else{ |
21 |
//we have a ball next to hero, check if it is empty on the other side of the ball
|
22 |
nextPos=GetNextPositionAlong(heroPos,direction); |
23 |
if(IsValidPosition(nextPos)){ |
24 |
if(!IsOccupied(nextPos)){//we found empty neighbor, so we need to move both ball & hero |
25 |
GameObject ball=GetOccupantAtPosition(heroPos);//find the ball at this position |
26 |
if(ball==null)Debug.Log("no ball"); |
27 |
RemoveOccupant(heroPos);//ball should be moved first before moving the hero |
28 |
ball.transform.position=GetScreenPointFromLevelIndices((int)nextPos.x,(int)nextPos.y); |
29 |
occupants[ball]=nextPos; |
30 |
if(levelData[(int)nextPos.x,(int)nextPos.y]==groundTile){ |
31 |
levelData[(int)nextPos.x,(int)nextPos.y]=ballTile; |
32 |
}else if(levelData[(int)nextPos.x,(int)nextPos.y]==destinationTile){ |
33 |
levelData[(int)nextPos.x,(int)nextPos.y]=ballOnDestinationTile; |
34 |
}
|
35 |
RemoveOccupant(oldHeroPos);//now move hero |
36 |
hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y); |
37 |
occupants[hero]=heroPos; |
38 |
if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){ |
39 |
levelData[(int)heroPos.x,(int)heroPos.y]=heroTile; |
40 |
}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){ |
41 |
levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile; |
42 |
}
|
43 |
}
|
44 |
}
|
45 |
}
|
46 |
CheckCompletion();//check if all balls have reached destinations |
47 |
}
|
48 |
}
|
Para obtener la siguiente posición a lo largo de una dirección determinada en función de una posición siempre, usamos el método GetNextPositionAlong. Es sólo una cuestión de incremento o decremento de los índices de acuerdo a la dirección.
1 |
private Vector2 GetNextPositionAlong(Vector2 objPos, int direction) |
2 |
{
|
3 |
switch(direction){ |
4 |
case 0: |
5 |
objPos.x-=1;//up |
6 |
break; |
7 |
case 1: |
8 |
objPos.y+=1;//right |
9 |
break; |
10 |
case 2: |
11 |
objPos.x+=1;//down |
12 |
break; |
13 |
case 3: |
14 |
objPos.y-=1;//left |
15 |
break; |
16 |
}
|
17 |
return objPos; |
18 |
}
|
Antes de mover héroe o bola, tenemos claro su posición actualmente ocupada en la matriz de levelData. Esto se hace utilizando el método de RemoveOccupant.
1 |
private void RemoveOccupant(Vector2 objPos) |
2 |
{
|
3 |
if(levelData[(int)objPos.x,(int)objPos.y]==heroTile||levelData[(int)objPos.x,(int)objPos.y]==ballTile){ |
4 |
levelData[(int)objPos.x,(int)objPos.y]=groundTile;//ball moving from ground tile |
5 |
}else if(levelData[(int)objPos.x,(int)objPos.y]==heroOnDestinationTile){ |
6 |
levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//hero moving from destination tile |
7 |
}else if(levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile){ |
8 |
levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//ball moving from destination tile |
9 |
}
|
10 |
}
|
Si nos encontramos con un heroTile o ballTile en el índice dado, tenemos que ponerlo a groundTile. Si nos encontramos con un heroOnDestinationTile o ballOnDestinationTile necesitamos ponerlo a destinationTile.
Finalización de Nivel
El nivel está completo cuando todas las bolas están en sus destinos.



Después de cada movimiento exitoso, llamamos al método CheckCompletion para ver si el nivel es completado. Bucle a través de nuestra gama de levelData y cuente el número de ocurrencias de ballOnDestinationTile. Si este número es igual al número total de bolas determinado por ballCount, el nivel es completo.
1 |
private void CheckCompletion() |
2 |
{
|
3 |
int ballsOnDestination=0; |
4 |
for (int i = 0; i < rows; i++) { |
5 |
for (int j = 0; j < cols; j++) { |
6 |
if(levelData[i,j]==ballOnDestinationTile){ |
7 |
ballsOnDestination++; |
8 |
}
|
9 |
}
|
10 |
}
|
11 |
if(ballsOnDestination==ballCount){ |
12 |
Debug.Log("level complete"); |
13 |
gameOver=true; |
14 |
}
|
15 |
}
|
Conclusión
Se trata de una simple y eficiente implementación de lógica sokoban. Puede crear sus propios niveles de alterar el archivo de texto o crear uno nuevo y cambiar la variable levelName para señalar el nuevo archivo de texto.
La implementación actual utiliza el teclado para controlar al héroe. Te invito a probar y cambiar el control a base de tap por lo que podemos soportar dispositivos táctiles. Esto implicaría agregar algún camino 2D encontrando así si te apetece tocar en cualquier azulejo para llevar allí el héroe.
Habrá un seguimiento tutorial donde exploraremos cómo el proyecto actual se puede utilizar para crear versiones de sokoban isométricas y hexagonales con cambios mínimos.



