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.
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+.
Veamos cómo hemos organizado nuestro proyecto Unity para este tutorial..
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.
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.
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..
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í.
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
.
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.
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.
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.
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.
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.
Diccionarioocupantes; // 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 eluserInputKeys
formación. Una vez determinada la dirección de movimiento requerida, llamamos a laTryMoveHero
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); // leftlos
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 destinosPara 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 elEliminarOccupant
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 destinoSi encontramos un
heroTile
opelota
en el índice dado, tenemos que establecerlo ensuelo
. Si encontramos unheroOnDestinationTile
oballOnDestinationTile
entonces tenemos que configurarlo paradestinationTile
.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 nuestrolevelData
Arregle y cuente el número deballOnDestinationTile
ocurrencias Si este número es igual a nuestro número total de bolas determinado porballCount
, 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..