Jeu 'Sokoban' basé sur les tuiles 2D

Ce que vous allez créer

Dans ce didacticiel, nous explorerons une approche permettant de créer un jeu de sokoban ou de pousseur de caisses à l’aide de la logique à base de mosaïques et d’un tableau à deux dimensions permettant de stocker des données de niveau. Nous utilisons Unity pour le développement avec C # comme langage de script. Veuillez télécharger les fichiers sources fournis avec ce tutoriel pour les suivre.

1. Le jeu de Sokoban

Parmi nous, il y en a peut-être peu qui n'ont peut-être pas joué à une variante du jeu Sokoban. La version originale peut même être plus ancienne que certains d'entre vous. S'il vous plaît consulter la page du wiki pour quelques détails. Nous avons essentiellement un personnage ou un élément contrôlé par l'utilisateur qui doit pousser des caisses ou des éléments similaires sur sa tuile de destination.. 

Le niveau consiste en une grille carrée ou rectangulaire de tuiles où une tuile peut être une marche ou une marche. Nous pouvons marcher sur les dalles praticables et y placer les caisses. Les tuiles spéciales pour les piétons seraient marquées comme des tuiles de destination, c’est là que la caisse devrait reposer avant de terminer le niveau. Le personnage est généralement contrôlé à l'aide d'un clavier. Une fois que toutes les caisses ont atteint une tuile de destination, le niveau est terminé.

Le développement basé sur les tuiles signifie essentiellement que notre jeu est composé d'un certain nombre de tuiles réparties de manière prédéterminée. Un élément de données de niveau représentera la manière dont les tuiles devraient être réparties pour créer notre niveau. Dans notre cas, nous utiliserons une grille à carreaux carrés. Vous pouvez en savoir plus sur les jeux basés sur des tuiles ici sur Envato Tuts+.

2. Préparer le projet Unity

Voyons comment nous avons organisé notre projet Unity pour ce tutoriel..

L'art

Pour ce projet de didacticiel, nous n'utilisons aucun actif d'art externe, mais nous allons utiliser les primitives de sprite créées avec la dernière version d'Unity 2017.1. L'image ci-dessous montre comment créer différentes images-objets dans Unity..

Nous allons utiliser le Carré sprite pour représenter une seule tuile dans notre grille de niveau sokoban. Nous allons utiliser le Triangle sprite pour représenter notre personnage, et nous allons utiliser le Cercle sprite pour représenter une caisse, ou dans ce cas une balle. Les carreaux au sol normaux sont blancs, tandis que les carreaux de destination ont une couleur différente pour se démarquer.

Les données de niveau

Nous allons représenter nos données de niveau sous la forme d'un tableau à deux dimensions qui fournit la corrélation parfaite entre les éléments logiques et visuels. Nous utilisons un simple fichier texte pour stocker les données de niveau, ce qui nous permet de modifier facilement le niveau en dehors de Unity ou de modifier les niveaux simplement en modifiant les fichiers chargés. le Ressources dossier a un niveau fichier texte, qui a notre niveau par défaut.

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

Le niveau comporte sept colonnes et cinq lignes. Une valeur de 1 signifie que nous avons une tuile au sol à cette position. Une valeur de -1 signifie qu'il s'agit d'une tuile non praticable, alors qu'une valeur de 0 signifie que c'est une tuile de destination. La valeur 2 représente notre héros, et 3 représente une balle pouvant être poussée. En regardant simplement les données de niveau, nous pouvons visualiser à quoi ressemblerait notre niveau..

3. Créer un niveau de jeu Sokoban

Pour garder les choses simples, et comme ce n’est pas une logique très compliquée, nous n’avons qu’un seul Sokoban.cs fichier de script pour le projet, et il est attaché à la caméra de la scène. Veuillez le garder ouvert dans votre éditeur pendant que vous suivez le reste du tutoriel..

Données de niveau spécial

Les données de niveau représentées par le tableau 2D sont non seulement utilisées pour créer la grille initiale, mais également tout au long du jeu pour suivre les changements de niveau et la progression de la partie. Cela signifie que les valeurs actuelles ne sont pas suffisantes pour représenter certains états de niveau pendant le jeu.. 

Chaque valeur représente l'état de la tuile correspondante dans le niveau. Nous avons besoin de valeurs supplémentaires pour représenter une balle sur la tuile de destination et le héros sur la tuile de destination, qui sont respectivement: -3 et -2. Ces valeurs peuvent être n'importe quelle valeur que vous attribuez dans le script de jeu, mais pas nécessairement les mêmes valeurs que celles utilisées ici.. 

Analyser le fichier texte de niveau

La première étape consiste à charger nos données de niveau dans un tableau 2D à partir du fichier texte externe. Nous utilisons le ParseLevel méthode pour charger le chaîne valeur et diviser pour peupler notre levelData Tableau 2D.

void ParseLevel () TextAsset textFile = Resources.Load (levelName) as TextAsset; string [] lines = textFile.text.Split (new [] '\ r', '\ n', System.StringSplitOptions.RemoveEmptyEntries); // scindé par une nouvelle ligne, retourne string [] nums = lines [0] .Split (new [] ','); // divisé en lignes = lignes.longueur; // nombre de lignes cols = nums.Length; // nombre de colonnes levelData = new int [lignes, colonnes]; pour (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;    

Lors de l'analyse, nous déterminons le nombre de lignes et de colonnes de notre niveau au fur et à mesure que nous peuplons notre levelData.

Niveau de dessin

Une fois que nous avons nos données de niveau, nous pouvons dessiner notre niveau à l'écran. Nous utilisons la méthode CreateLevel pour faire exactement cela.

void CreateLevel () // calcule le décalage pour aligner le niveau entier sur la scène middle middleOffset.x = cols * tileSize * 0.5f-tileSize * 0.5f; middleOffset.y = rows * tileSize * 0.5f-tileSize * 0.5f ;; Tuile GameObject; SpriteRenderer sr; Balle GameObject; int destinationCount = 0; pour (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(); // ajout d'un rendu d'image-objet sr.sprite = tileSprite; // attribuer une image-objet à la mosaïque tile.transform.position = GetScreenPointFromLevelIndices (i, j); // lieu dans la scène en fonction des indices de niveau if (val == destinationTile)  // s'il s'agit d'une mosaïque de destination, donnez une couleur différente sr.color = destinationColor; destinationCount ++; // compte les destinations else if (val == heroTile) // la tuile héros Hero = new GameObject ("hero"); hero.transform.localScale = Vector2.one * (tileSize-1); sr = hero.AddComponent(); sr.sprite = heroSprite; sr.sortingOrder = 1; // le héros doit être sur la tuile du sol sr.color = Color.red; hero.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (héros, nouveau vecteur2 (i, j)); // stocke les indices de niveau du héros dans dict else if (val == ballTile) // balle tuile balleCompte ++; // incrémente le nombre de balles dans une balle de niveau = new GameObject ("ball" + ballCount.ToString ()); ball.transform.localScale = Vector2.one * (tileSize-1); sr = ball.AddComponent(); sr.sprite = ballSprite; sr.sortingOrder = 1; // la balle doit être au-dessus du pavé de sol sr.color = Color.black; ball.transform.position = GetScreenPointFromLevelIndices (i, j); occupants.Add (ball, new Vector2 (i, j)); // stocke les indices de niveau de ball dans dict if (ballCount> destinationCount) Debug.LogError ("il y a plus de balles que de destinations"); 

Pour notre niveau, nous avons défini un TailleTuile valeur de 50, qui est la longueur du côté d'une tuile carrée dans notre grille de niveau. Nous parcourons notre tableau 2D et déterminons la valeur stockée dans chacun des je et j indices du tableau. Si cette valeur n'est pas un invalidTile (-1) alors on crée un nouveau GameObject nommé tuile. Nous attachons un SpriteRenderer composant à tuile et assigner le correspondant Lutin ou Couleur en fonction de la valeur à l'index du tableau. 

En plaçant le héros ou la ballon, nous devons d'abord créer une tuile de terre, puis créer ces tuiles. Comme le héros et la balle doivent superposer la tuile au sol, nous leur donnons SpriteRenderer un plus haut ordre de tri. Toutes les tuiles se voient attribuer un échelle locale de TailleTuile donc ils sont 50x50 dans notre scène. 

Nous gardons une trace du nombre de balles dans notre scène en utilisant le ballCount variable, et il devrait y avoir un nombre identique ou supérieur de tuiles de destination dans notre niveau pour permettre l’achèvement du niveau. La magie se produit dans une seule ligne de code où nous déterminons la position de chaque tuile en utilisant le GetScreenPointFromLevelIndices (int row, int col) méthode.

//… tile.transform.position = GetScreenPointFromLevelIndices (i, j); // place dans la scène en fonction des index de niveau //… Vector2 GetScreenPointFromLevelIndices (int row, int col) // conversion d'index en valeurs de position, col détermine x & row détermine y retourne le nouveau Vector2 (col * tileSize-middleOffset.x, row * -tileSize + middleOffset.y); 

La position mondiale d’une tuile est déterminée en multipliant les indices de niveau par le TailleTuile valeur. le middleOffset variable est utilisé pour aligner le niveau au milieu de l'écran. Notez que le rangée la valeur est multipliée par une valeur négative afin de supporter l'inversion y axe dans l'unité.

4. La logique de Sokoban

Maintenant que nous avons affiché notre niveau, passons à la logique de jeu. Nous devons écouter la saisie par la touche utilisateur et déplacer le curseur. héros basé sur l'entrée. L’appui sur la touche détermine la direction requise du mouvement, et le héros doit être déplacé dans cette direction. Il existe différents scénarios à considérer une fois que nous avons déterminé la direction requise du mouvement. Disons que la tuile à côté de héros dans cette direction est tuileK.

  • Y at-il une tuile dans la scène à cet endroit ou est-ce en dehors de notre grille??
  • Le carrelage est-il un carrelage praticable?
  • La tuile est-elle occupée par un ballon??

Si la position de tileK est en dehors de la grille, nous n'avons rien à faire. Si tileK est valide et peut être parcouru à pied, nous devons nous déplacer héros à cette position et mettre à jour notre levelData tableau. Si tileK a un ballon, alors nous devons considérer le prochain voisin dans la même direction, disons tuileL.

  • Le carreau est-il en dehors de la grille??
  • Le carrelage est-il un carrelage praticable?
  • Le carreau est-il occupé par un ballon??

Dans le cas où tuileL est une tuile accessible à pied et non occupée, faut-il déplacer le héros et la balle à tileK à tileK et tileL respectivement. Après un mouvement réussi, nous devons mettre à jour le levelData tableau.

Fonctions de support

La logique ci-dessus signifie que nous devons savoir quelle tuile notre héros est actuellement à. Nous devons également déterminer si une certaine tuile a une balle et devrait y avoir accès.. 

Pour faciliter cela, nous utilisons un dictionnaire appelé les occupants qui stocke un GameObject comme clé et ses indices de tableau stockés sous Vecteur2 comme valeur. dans le CreateLevel méthode, nous peuplons les occupants quand on crée héros ou balle. Une fois le dictionnaire rempli, nous pouvons utiliser le GetOccupantAtPosition pour récupérer le GameObject à un index de tableau donné.

dictionnaire occupants; // référence aux balles & héros //… occupants.Add (héros, nouveau vecteur2 (i, j)); // stocke les index de niveau du héros dans dict //… occupants.Add (balle, nouveau vecteur2 (i , j)); // stocke les indices de niveau de la balle dans dict //… private GameObject GetOccupantAtPosition (Vector2 heroPos) // parcourt les occupants pour trouver la balle à la position donnée GameObject ball; foreach (KeyValuePair paire dans les occupants) if (pair.Value == heroPos) ball = pair.Key; balle de retour;  return null; 

le Est occupé méthode détermine si le levelData la valeur aux indices fournis représente une balle.

private bool IsOccupied (Vector2 objPos) // vérifie s'il y a une balle à la position de tableau donnée (levelData [(int) objPos.x, (int) objPos.y] == ballTile || levelData [(int) objPos. x, (int) objPos.y] == ballOnDestinationTile); 

Nous avons également besoin d'un moyen de vérifier si une position donnée se trouve dans notre grille et si cette tuile est praticable. le IsValidPosition méthode vérifie les indices de niveau passés en tant que paramètres pour déterminer s’il se situe ou non dans nos dimensions de niveau. Il vérifie également si nous avons un invalidTile comme cet indice dans le levelData.

private bool IsValidPosition (Vector2 objPos) // vérifie si les index donnés entrent dans les dimensions du tableau if (objPos.x> -1 && objPos.x-1 && objPos.y

Répondre à la saisie de l'utilisateur

dans le Mettre à jour méthode de notre script de jeu, nous vérifions pour l'utilisateur KeyUp événements et comparer avec nos clés d'entrée stockées dans le userInputKeys tableau. Une fois que la direction requise du mouvement est déterminée, nous appelons le TryMoveHero méthode avec la direction en paramètre.

void Update () if (gameOver) return; ApplyUserInput (); // vérifier et utiliser les entrées utilisateur pour déplacer le héros et les balles 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); // bas autrement if (Input.GetKeyUp (userInputKeys [2]). 3])) TryMoveHero (3); // left

le TryMoveHero La méthode est celle où notre logique de jeu principale expliquée au début de cette section est mise en œuvre. Veuillez suivre attentivement la méthode suivante pour voir comment la logique est implémentée comme expliqué ci-dessus..

TryMoveHero (int direction) Vector2 heroPos; Vector2 oldHeroPos; Vector2 nextPos; occupants.TryGetValue (héros, hors oldHeroPos); heroPos = GetNextPositionAlong (oldHeroPos, direction); // trouve la position du tableau suivant dans une direction donnée si (IsValidPosition (heroPos)) // vérifie si cette position est valide et tombe dans le tableau de niveaux si (! IsOccupied (heroPos)) // vérifie si elle est occupée par une balle // déplace le héros RemoveOccupant (oldHeroPos); // réinitialise les données de l'ancien niveau à l'ancienne position hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y ) occupants [héros] = heroPos; if (levelData [(int) heroPos.x, (int) heroPos.y] == groundTile) // se déplaçant sur une tuile de sol levelData [(int) heroPos.x, (int) heroPos.y] = heroTile;  else if (levelData [(int) heroPos.x, (int) heroPos.y] == destinationTile) // passage sur une tuile de destination levelData [(int) heroPos.x, (int) heroPos.y] = heroOnDestinationTile ;  else // nous avons une balle à côté de hero, vérifiez si elle est vide de l'autre côté de la balle nextPos = GetNextPositionAlong (heroPos, direction); if (IsValidPosition (nextPos)) if (! IsOccupied (nextPos)) // nous avons trouvé un voisin vide; nous devons donc déplacer la balle et le héros. (ball == null) Debug.Log ("pas de balle"); RemoveOccupant (heroPos); // ball doit d'abord être déplacé avant de déplacer le héros ball.transform.position = GetScreenPointFromLevelIndices ((int) nextPos.x, (int) nextPos.y); occupants [balle] = 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); // déplace maintenant le héros hero.transform.position = GetScreenPointFromLevelIndices ((int) heroPos.x, (int) heroPos.y); occupants [héros] = 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 (); // vérifie si toutes les balles ont atteint la destination

Afin d’obtenir la position suivante dans une certaine direction sur la base d’une position fournie, nous utilisons le GetNextPositionAlong méthode. Il s’agit simplement d’augmenter ou de décrémenter l’un ou l’autre des indices en fonction de la direction.

private Vector2 GetNextPositionAlong (Vector2 objPos, direction int) commutateur (direction) cas 0: objPos.x- = 1; // up pause; cas 1: objPos.y + = 1; // pause droite; cas 2: objPos.x + = 1; // down break; cas 3: objPos.y- = 1; // pause à gauche;  return objPos; 

Avant de déplacer le héros ou la balle, nous devons effacer leur position actuellement occupée dans levelData tableau. Ceci est fait en utilisant le RemoveOccupant méthode.

espace privé privé 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] = balle au sol; // balle se déplaçant depuis la tuile au sol else if (levelData [(int) objPos.x, (int) objPos.y] == heroOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // héros se déplaçant de la mosaïque de destination else if (levelData [(int) objPos.x, (int) objPos.y] = = ballOnDestinationTile) levelData [(int) objPos.x, (int) objPos.y] = destinationTile; // la balle se déplace de la tuile de destination

Si on trouve un heroTile ou ballTile à l'index donné, nous devons le mettre à carrelage. Si on trouve un heroOnDestinationTile ou ballOnDestinationTile alors nous devons le régler sur destinationTile.

Niveau d'achèvement

Le niveau est terminé lorsque toutes les balles sont à leur destination.

Après chaque mouvement réussi, nous appelons le CheckCompletion méthode pour voir si le niveau est terminé. Nous parcourons notre levelData tableau et compter le nombre de ballOnDestinationTile occurrences. Si ce nombre est égal à notre nombre total de balles déterminé par ballCount, le niveau est complet.

private void CheckCompletion () int ballsOnDestination = 0; pour (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;  

Conclusion

Ceci est une implémentation simple et efficace de la logique sokoban. Vous pouvez créer vos propres niveaux en modifiant le fichier texte ou en créant un nouveau et en modifiant le levelName variable pour pointer vers votre nouveau fichier texte. 

L'implémentation actuelle utilise le clavier pour contrôler le héros. Je vous invite à essayer de modifier le contrôle en fonction de la prise en charge afin que nous puissions prendre en charge les périphériques tactiles. Cela impliquerait également l'ajout de chemins de recherche en 2D si vous avez envie de toucher n'importe quelle tuile pour y conduire le héros..

Il y aura un tutoriel de suivi où nous explorerons comment le projet actuel peut être utilisé pour créer des versions isométriques et hexagonales de sokoban avec des modifications minimes..