Juego 'Sokoban' basado en azulejos de Unity 2D

Lo que vas a crear

En este tutorial, exploraremos un enfoque para crear un juego de sokoban o de empujar cajas utilizando una lógica basada en mosaicos y una matriz bidimensional para mantener los datos de nivel. Estamos utilizando Unity para el desarrollo con C # como lenguaje de scripting. Por favor, descargue los archivos fuente provistos con este tutorial para seguirlos.

1. El juego de Sokoban

Puede que haya pocos entre nosotros que no hayan jugado una variante de juego de Sokoban. La versión original puede incluso ser más antigua que algunos de ustedes. Por favor, echa un vistazo a la página wiki para algunos detalles. Esencialmente, tenemos un personaje o elemento controlado por el usuario que tiene que empujar cajas o elementos similares en su mosaico de destino. 

El nivel consiste en una cuadrícula cuadrada o rectangular de baldosas donde una baldosa puede ser una no transitable o una transitable. Podemos caminar sobre las baldosas transitables y empujar las cajas sobre ellas. Los azulejos transitables especiales se marcarían como azulejos de destino, que es donde la caja debería eventualmente descansar para completar el nivel. El personaje generalmente se controla mediante un teclado. Una vez que todas las cajas han alcanzado una ficha de destino, el nivel está completo.

El desarrollo basado en mosaicos esencialmente significa que nuestro juego se compone de una serie de mosaicos distribuidos de una manera predeterminada. Un elemento de datos de nivel representará cómo se necesitarían los mosaicos para crear nuestro nivel. En nuestro caso, usaremos una cuadrícula basada en baldosas cuadradas. Puedes leer más sobre juegos basados ​​en azulejos aquí en Envato Tuts+.

2. Preparando el Proyecto de Unidad.

Veamos cómo hemos organizado nuestro proyecto Unity para este tutorial..

El arte

Para este proyecto tutorial, no estamos usando ningún activo de arte externo, pero usaremos los primitivos de sprites creados con la última versión de Unity 2017.1. La imagen de abajo muestra cómo podemos crear diferentes sprites con forma dentro de Unity..

Usaremos el Cuadrado sprite para representar una sola ficha en nuestra cuadrícula de nivel sokoban. Usaremos el Triángulo sprite para representar a nuestro personaje, y vamos a utilizar el Circulo sprite para representar una caja, o en este caso una bola. Los azulejos de suelo normales son blancos, mientras que los azulejos de destino tienen un color diferente para destacar.

Los datos de nivel

Estaremos representando nuestros datos de nivel en forma de una matriz bidimensional que proporciona la correlación perfecta entre la lógica y los elementos visuales. Utilizamos un archivo de texto simple para almacenar los datos de nivel, lo que nos facilita editar el nivel fuera de Unity o cambiar los niveles simplemente cambiando los archivos cargados. los Recursos carpeta tiene una nivel Archivo de texto, que tiene nuestro nivel por defecto..

1,1,1,1,1,1,1 1,3,1, -1,1,0,1 -1,0,1,2,1,1 -1 -1,1,1,3, 1,3,1 1,1,0, -1,1,1,1

El nivel tiene siete columnas y cinco filas. Un valor de 1 significa que tenemos una baldosa de tierra en esa posición. Un valor de -1 significa que es un azulejo no transitable, mientras que un valor de 0 significa que es una ficha de destino. El valor 2 representa a nuestro héroe, y 3 representa una pelota empujable. Con solo mirar los datos de nivel, podemos visualizar cómo se vería nuestro nivel.

3. Creando un nivel de juego de Sokoban

Para mantener las cosas simples, y como no es una lógica muy complicada, solo tenemos una Sokoban.cs archivo de script para el proyecto, y se adjunta a la cámara de escena. Manténgalo abierto en su editor mientras sigue el resto del tutorial..

Datos de nivel especial

Los datos de nivel representados por la matriz 2D no solo se usan para crear la cuadrícula inicial, sino que también se usan a lo largo del juego para rastrear los cambios de nivel y el progreso del juego. 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 del mosaico correspondiente en el nivel. Necesitamos valores adicionales para representar una bola en la ficha de destino y el héroe en la ficha de destino, que respectivamente -3 y -2. Estos valores podrían ser cualquier valor que asigne en el script del juego, no necesariamente los mismos valores que hemos usado aquí. 

Analizando el archivo de texto de nivel

El primer paso es cargar nuestros datos de nivel en una matriz 2D desde el archivo de texto externo. Usamos el ParseLevel método para cargar el cuerda Valorar y dividirlo para poblar nuestra levelData Matriz 2D.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) como TextAsset; string [] lines = textFile.text.Split (new [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // dividido por nueva línea, devuelve string [] nums = lines [0] .Split (new [] ','); // split by, rows = lines.Length; // número de filas cols = nums.Length; // número de columnas levelData = new int [rows, cols]; para (int i = 0; i < rows; i++)  string st = lines[i]; nums = st.Split(new[]  ',' ); for (int j = 0; j < cols; j++)  int val; if (int.TryParse (nums[j], out val)) levelData[i,j] = val;  else levelData[i,j] = invalidTile;    

Mientras analizamos, determinamos el número de filas y columnas que tiene nuestro nivel al rellenar nuestro levelData.

Nivel de dibujo

Una vez que tenemos nuestros datos de nivel, podemos dibujar nuestro nivel en la pantalla. Usamos el método CreateLevel para hacer precisamente eso..

void CreateLevel () // calcula el desplazamiento para alinear todo el nivel con la escena middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = rows * tileSize * 0.5f-tileSize * 0.5f ;; Azulejo de GameObject; SpriteRenderer sr; GameObject pelota; int destinationCount = 0; para (int i = 0; i < rows; i++)  for (int j = 0; j < cols; j++)  int val=levelData[i,j]; if(val!=invalidTile)//a valid tile tile = new GameObject("tile"+i.ToString()+"_"+j.ToString());//create new tile tile.transform.localScale=Vector2.one*(tileSize-1);//set tile size sr = tile.AddComponent(); // agregue un renderizador de sprites sr.sprite = tileSprite; // asigne un sprite de azulejo tile.transform.position = GetScreenPointFromLevelIndices (i, j); // coloque en la escena según los índices de nivel if (val == destinationTile)  // si es un mosaico de destino, dale un color diferente sr.color = destinationColor; destinationCount ++; // contar destinos else if (val == heroTile) // the hero hero hero = new GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // el héroe debe estar sobre el mosaico del suelo sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (hero, new Vector2 (i, j)); // almacena los índices de nivel de hero en dict else if (val == ballTile) // ball tile ballCount ++; // incrementa el número de bolas en level ball = nuevo GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // la bola debe estar sobre el azulejo del suelo sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (ball, nuevo Vector2 (i, j)); // almacena los índices de nivel de ball en dict if (ballCount> destinationCount) Debug.LogError ("hay más bolas que destinos"); 

Para nuestro nivel, hemos establecido un tamaño del azulejo valor de 50, que es la longitud del lado de un azulejo cuadrado en nuestra cuadrícula de nivel. Recorremos nuestra matriz 2D y determinamos el valor almacenado en cada uno de los yo y j Índices de la matriz. Si este valor no es un invalidTile (-1) luego creamos una nueva GameObject llamado azulejo. Adjuntamos un SpriteRenderer componente a azulejo y asignar las correspondientes Duende o Color Dependiendo del valor en el índice de matriz. 

Mientras se coloca el héroe o la bola, Primero debemos crear un mosaico de suelo y luego crear estos mosaicos. Como el héroe y la bola deben superponerse a la baldosa del suelo, damos su SpriteRenderer una mayor ordenando. A todas las fichas se les asigna un Escala local de tamaño del azulejo entonces ellos son 50x50 en nuestra escena. 

Realizamos un seguimiento del número de bolas en nuestra escena utilizando el ballCount variable, y debería haber el mismo o un mayor número de casillas de destino en nuestro nivel para que sea posible completar el nivel. La magia ocurre en una sola línea de código donde determinamos la posición de cada ficha usando el GetScreenPointFromLevelIndices (int row, int col) método.

//… tile.transform.position = GetScreenPointFromLevelIndices (i, j); // colocar en escena según los índices de nivel // ... Vector2 GetScreenPointFromLevelIndices (int row, int col) // convertir los índices en valores de posición, col determina x & la fila determina y devuelve el nuevo Vector2 (col * tileSize-middleOffset.x, row * -tileSize + middleOffset.y); 

La posición mundial de una baldosa se determina multiplicando los índices de nivel con el tamaño del azulejo valor. los middleOffset La variable se usa para alinear el nivel en el centro de la pantalla. Tenga en cuenta que el fila El valor se multiplica por un valor negativo para respaldar la inversión invertida. y eje en la unidad.

4. Lógica Sokoban

Ahora que hemos mostrado nuestro nivel, procedamos a la lógica del juego. Necesitamos escuchar la entrada de la tecla del usuario y mover la héroe basado en la entrada. La pulsación de tecla determina una dirección de movimiento requerida, y la héroe necesita ser movido en esa dirección Hay varios escenarios a considerar una vez que hayamos determinado la dirección de movimiento requerida. Digamos que la baldosa al lado de héroe en esta dirección es azulejo.

  • ¿Hay un mosaico en la escena en esa posición, o está fuera de nuestra cuadrícula??
  • ¿Es la baldosa una baldosa transitable??
  • ¿Está el azulejo ocupado por una bola??

Si la posición de tileK está fuera de la cuadrícula, no necesitamos hacer nada. Si tileK es válido y es manejable, entonces debemos movernos héroe a esa posición y actualizar nuestra levelData formación. Si tileK tiene una bola, entonces debemos considerar al próximo vecino en la misma dirección, digamos azulejo.

  • Es TileL fuera de la red?
  • Es azulejo un azulejo transitable?
  • ¿Está la baldosa ocupada por una pelota??

Solo en el caso de que tileL sea una baldosa no ocupable transitable, deberíamos mover la héroe y la bola en tileK a tileK y tileL respectivamente. Después de un movimiento exitoso, necesitamos actualizar el levelData formación.

Funciones de apoyo

La lógica anterior significa que necesitamos saber qué mosaico nuestro héroe se encuentra actualmente en. También debemos determinar si una determinada ficha tiene una bola y debería tener acceso a esa bola. 

Para facilitar esto, utilizamos un Diccionario llamado ocupantes que almacena un GameObject como clave y sus índices de matriz almacenados como Vector2 como valor. En el CrearNivel método, poblamos ocupantes cuando creamos héroe o pelota Una vez que tenemos el diccionario poblado, podemos usar el GetOccupantAtPosition para recuperar el GameObject en un índice de matriz dado.

Diccionario ocupantes; // referencia a pelotas y héroe //… ocupantes.Agregar (héroe, nuevo Vector2 (i, j)) // almacenar los índices de nivel del héroe en dict //… ocupantes.Agregar (bola, nuevo Vector2 (i , j)); // almacena los índices de nivel de bola en dict //… juego privado GameObject GetOccupantAtPos (Vector2 heroPos) // recorre a los ocupantes para encontrar la bola en la posición dada pelota GameObject; foreach (KeyValuePair par en ocupantes) if (pair.Value == heroPos) ball = pair.Key; bola de retorno  devolver nulo; 

los Esta ocupado método determina si el levelData El valor en los índices proporcionados representa una bola..

bool privado IsOccupied (Vector2 objPos) // verifique si hay una bola en el retorno de posición de la matriz dada (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

También necesitamos una forma de verificar si una posición dada está dentro de nuestra cuadrícula y si esa baldosa es transitable. los IsValidPosition el método verifica los índices de nivel pasados ​​como parámetros para determinar si cae dentro de nuestras dimensiones de nivel. También verifica si tenemos un invalidTile como ese índice en el levelData.

private bool IsValidPosition (Vector2 objPos) // verifique si los índices dados están dentro de las dimensiones de la matriz if (objPos.x> -1 && objPos.x-1 && objPos.y

Respondiendo a la entrada del usuario

En el Actualizar Método de nuestro script de juego, comprobamos el usuario. Tecla Arriba eventos y comparar con nuestras teclas de entrada almacenadas en el userInputKeys formación. Una vez determinada la dirección de movimiento requerida, llamamos a la TryMoveHero Método con la dirección como parámetro..

void Update () if (gameOver) return; ApplyUserInput (); // verifica y usa la entrada del usuario para mover hero and balls private void ApplyUserInput () if (Input.GetKeyUp (userInputKeys [0])) TryMoveHero (0); // up else if (Input. GetKeyUp (userInputKeys [1])) TryMoveHero (1); // right else if (Input.GetKeyUp (userInputKeys [2])) TryMoveHero (2); // down else if (Input.GetKeyUp (userInputKeys [ 3])) TryMoveHero (3); // left

los TryMoveHero El método es donde se implementa nuestra lógica de juego principal que se explica al inicio de esta sección. Siga detenidamente el siguiente método para ver cómo se implementa la lógica como se explicó anteriormente..

vacío privado TryMoveHero (int direction) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (héroe, fuera de oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // encuentra la siguiente posición de la matriz en una dirección dada si (IsValidPosition (heroPos)) // comprueba si es una posición válida y cae dentro de la matriz de nivel if (! IsOccupied (heroPos)) // verifique si está ocupado por una bola // mover al héroe RemoveOccupant (oldHeroPos); // restablecer los datos del nivel anterior en la posición anterior hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ); ocupantes [hero] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // moviéndose a un nivel de mosaico de suelo levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // moviéndose a un mosaico de destino levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  else else // tenemos una bola al lado del héroe, verifica si está vacía en el otro lado de la bola nextPos = GetNextPositionAlong (heroPos, direction); if (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // encontramos un vecino vacío, por lo que necesitamos mover la bola y el héroe GameObject ball = GetOccupantAtPosition (heroPos); // buscar la bola en esta posición si (ball == null) Debug.Log ("no ball"); RemoveOccupant (heroPos); // la bola debe moverse primero antes de mover el héroe ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); ocupantes [bola] = nextPos; if (levelData [(int) nextPos.x, (int) nextPos.y] == groundTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballTile;  else if (levelData [(int) nextPos.x, (int) nextPos.y] == destinationTile) levelData [(int) nextPos.x, (int) nextPos.y] = ballOnDestinationTile;  RemoveOccupant (oldHeroPos); // ahora mueve hero hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); ocupantes [hero] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile;  CheckCompletion (); // verifica si todas las bolas han alcanzado los destinos

Para obtener la siguiente posición a lo largo de una determinada dirección basada en una posición proporcionada, utilizamos la GetNextPositionAlong método. Es solo una cuestión de aumentar o disminuir cualquiera de los índices de acuerdo a la dirección.

private Vector2 GetNextPositionAlong (Vector2 objPos, int direction) switch (direction) caso 0: objPos.x- = 1; // up break; caso 1: objPos.y + = 1; // salto derecho; caso 2: objPos.x + = 1; // down break; caso 3: objPos.y- = 1; // break izquierdo;  devolver objPos; 

Antes de mover héroe o bola, debemos despejar su posición actualmente ocupada en el levelData formación. Esto se hace usando el EliminarOccupant método.

privado void RemoveOccupant (Vector2 objPos) if (levelData [(int) objPos.x, (int) objPos.y] == heroTile || levelData [(int) objPos.x, (int) objPos.y] == ballTile ) levelData [(int) objPos.x, (int) objPos.y] = GroundTile; // bola que se mueve desde el mosaico del suelo else if (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // hero que se mueve desde el mosaico de destino else if (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // bola que se mueve desde el mosaico de destino

Si encontramos un heroTile o pelota en el índice dado, tenemos que establecerlo en suelo. Si encontramos un heroOnDestinationTile o ballOnDestinationTile entonces tenemos que configurarlo para 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 CheckCompletion Método para ver si el nivel se ha completado. Recorremos nuestro levelData Arregle y cuente el número de ballOnDestinationTile ocurrencias Si este número es igual a nuestro número total de bolas determinado por ballCount, el nivel esta completo.

vacío privado CheckCompletion () int ballsOnDestination = 0; para (int i = 0; i < rows; i++)  for (int j = 0; j < cols; j++)  if(levelData[i,j]==ballOnDestinationTile) ballsOnDestination++;    if(ballsOnDestination==ballCount) Debug.Log("level complete"); gameOver=true;  

Conclusión

Esta es una implementación simple y eficiente de la lógica sokoban. Puede crear sus propios niveles modificando el archivo de texto o creando uno nuevo y cambiando el nivelNombre Variable para apuntar a tu nuevo archivo de texto.. 

La implementación actual utiliza el teclado para controlar al héroe. Lo invitaría a probar y cambiar el control a base de toque para que podamos admitir dispositivos táctiles. Esto implicaría agregar también un camino de búsqueda 2D si te gusta hacer tapping en cualquier ficha para llevar al héroe allí.

Habrá un tutorial de seguimiento donde exploraremos cómo se puede utilizar el proyecto actual para crear versiones isométricas y hexagonales de sokoban con cambios mínimos..