Créer roguelike dans Unity à partir de zéro: générateur de donjon

image

Cette fois, nous allons plonger dans l'implémentation de l'algorithme du générateur de donjon. Dans le dernier article, nous avons créé la première salle, et maintenant nous allons générer le reste du niveau du donjon.

Mais avant de commencer, je voudrais corriger une erreur d'un post précédent. En fait, au cours des dernières semaines, j'ai appris quelque chose de nouveau, c'est pourquoi une partie du travail que j'ai fait est dépassée et je veux en parler.

Vous vous souvenez de la classe Position que nous avons créée? En fait, Unity a déjà une classe intégrée qui exécute exactement les mêmes fonctions, mais avec un contrôle légèrement meilleur - il est plus facile de déclarer et de traiter. Cette classe est appelée Vector2Int. Par conséquent, avant de commencer, nous allons supprimer la classe Position de MapManager.cs et remplacer chaque variable Position par la variable Vector2Int.


La même chose doit être effectuée à plusieurs endroits dans le script DungeonGenerator.cs. Passons maintenant au reste de l'algorithme.

Étape 7 - génération de salle / hall


Nous allons commencer par une petite modification de la fonction FirstRoom () créée la dernière fois. Au lieu de créer une autre fonction pour générer tous les autres éléments de la carte et dupliquer un tas de code, nous transformons simplement cette fonction, en la transformant en une GenerateFeature () généralisée. Par conséquent, modifiez le nom de FirstRoom en GenerateFeature.

Nous allons maintenant devoir passer des paramètres à cette fonction. Tout d'abord, vous devez savoir quelle fonction il génère - une pièce ou un couloir. Nous pouvons simplement passer une chaîne appelée type. Ensuite, la fonction doit connaître le point de départ de l'élément, c'est-à-dire de quel mur elle provient (car nous créons toujours un nouvel élément à partir du mur de l'élément plus ancien), et pour cela, passer comme argument Wall est suffisant. Enfin, la première pièce à créer a des caractéristiques spéciales, nous avons donc besoin d'une variable booléenne facultative qui indique si l'élément est la première pièce. Par défaut, c'est faux: bool isFirst = false. Le titre de la fonction changera donc de ceci:


sur ce:


Bien. L'étape suivante consiste à modifier la façon dont vous calculez la largeur et la hauteur de l'élément. Pendant que nous les calculons, obtenir une valeur aléatoire entre les valeurs min et max de la hauteur et de la largeur des pièces - c'est idéal pour les pièces, mais ne fonctionnera pas pour les couloirs. Donc, jusqu'à présent, nous avons les éléments suivants:


Mais les couloirs auront une taille constante de 3 en largeur ou en hauteur, selon l'orientation. Par conséquent, nous devons vérifier ce qu'est l'élément - une pièce ou un couloir, puis effectuer les calculs appropriés.


Donc. nous vérifions si l'article est une pièce. Si oui, alors nous faisons la même chose qu'avant
- nous obtenons un nombre aléatoire dans l'intervalle entre min et max de hauteur et de largeur. Mais maintenant, sinon, si vous devez faire quelque chose d'un peu différent. Nous devons vérifier l'orientation du couloir. Heureusement, lors de la génération d'un mur, nous enregistrons des informations sur la direction dans laquelle il est dirigé, nous l'utilisons donc pour obtenir l'orientation du couloir.


Mais nous n'avons pas encore déclaré la variable minCorridorLength. Vous devez revenir aux déclarations de variables et les déclarer, juste au-dessus de maxCorridorLength.


Revenons maintenant à nos instructions de changement conditionnel. Ce que nous faisons ici: nous obtenons la valeur de la direction du mur, c'est-à-dire où le mur regarde, d'où le couloir ira. La direction ne peut avoir que quatre valeurs possibles: Sud, Nord, Ouest et Est. Dans le cas du Sud et du Nord, le couloir aura une largeur de 3 (deux murs et un plancher au milieu) et une hauteur variable (longueur). Pour l'Ouest et l'Est, tout sera inversé: la hauteur sera constamment égale à 3 et la largeur aura une longueur variable. Alors faisons-le.


Sensationnel. Et c'est là que nous avons fini par dimensionner le nouvel article. Maintenant, vous devez décider où le mettre. Nous avons placé la première pièce dans un endroit aléatoire dans les valeurs de seuil par rapport au centre de la carte.


Mais pour tous les autres éléments, cela ne fonctionnera pas. Ils doivent commencer à côté du point aléatoire sur le mur à partir duquel l'élément est généré. Modifions donc le code. Tout d'abord, nous devons vérifier si l'élément est la première pièce. S'il s'agit de la première pièce, nous définissons les points de départ de la même manière que précédemment - comme la moitié de la largeur et de la hauteur de la carte.


Dans d' autre, si l'élément est pas la première chambre, nous obtenons un point au hasard sur le mur à partir de laquelle l'élément est généré. Tout d'abord, nous devons vérifier si le mur a une taille de 3 (cela signifie qu'il s'agit du point final du couloir), et si c'est le cas, le point central sera toujours sélectionné, c'est-à-dire l'index 1 du tableau de murs (avec 3 éléments, le tableau a indices 0, 1, 2). Mais si la taille n'est pas égale à 3 (le mur n'est pas le point final du couloir), alors nous prenons un point aléatoire dans l'intervalle entre le point 1 et la longueur du mur moins 2. Ceci est nécessaire pour éviter les passages créés dans le coin. C'est-à-dire, par exemple, sur un mur d'une longueur de 6, nous excluons les index 0 et 5 (premier et dernier) et sélectionnons un point aléatoire parmi les points 1, 2, 3 et 4.


Nous avons maintenant la position du point sur le mur où un nouvel élément sera créé. Mais nous ne pouvons pas simplement commencer à générer un élément à partir de là, car de cette façon, il sera bloqué par des murs déjà placés. Il est également important de noter que l'élément commence à être généré à partir de son coin inférieur gauche, puis l'incrémentation est effectuée vers la droite et vers le haut, nous devons donc définir la position initiale à différents endroits, selon la direction dans laquelle le mur regarde. De plus, la première colonne x et la première ligne y seront des murs, et si nous commençons un nouvel élément juste à côté d'un point sur le mur, nous pouvons créer un couloir se terminant dans un coin de la pièce et non à un endroit approprié sur le mur.

Donc, si le mur est dirigé vers le nord, il est nécessaire que l'élément commence dans une position au nord le long de l'axe y, mais dans un nombre aléatoire de positions à l'ouest le long de l'axe x, dans la plage de 1 à la largeur de la pièce-2. Dans la direction sud, l'axe x agit de la même façon, mais la position de départ sur l'axe y est la position du point sur le mur moins la hauteur de la pièce. Les murs ouest et est suivent la même logique, uniquement avec des axes inversés.

Mais avant de faire tout cela, nous devons enregistrer la position du point de mur dans la variable Vector2Int afin de pouvoir le manipuler plus tard.


Génial. Faisons cela.


Nous avons donc généré un élément avec la taille et la position, et l'étape suivante consiste à placer l'élément sur la carte. Mais d'abord, nous devons savoir s'il y a vraiment de la place sur la carte pour cet élément dans cette position. Pour l'instant, nous appelons simplement la fonction CheckIfHasSpace (). Il sera surligné en rouge, car nous ne l'avons pas encore mis en œuvre. Nous le ferons juste après avoir terminé ce qui doit être fait ici dans la fonction GenerateFeature (). Par conséquent, ignorez le soulignement rouge et continuez.


Dans la partie suivante, des murs sont créés. Jusqu'à ce que nous le touchions, à l'exception du fragment dans la seconde boucle for .


En écrivant ce post, j'ai remarqué que ces constructions if-else sont complètement fausses. Par exemple, certains murs en eux recevront une longueur de 1. Cela se produit parce que lorsque la position doit être ajoutée, par exemple, au mur nord, alors si elle était au coin du mur est, elle ne sera pas ajoutée au mur est, comme il se doit. Cela a provoqué des bogues ennuyeux dans l'algorithme de génération. Éliminons-les.

Les réparer est assez simple. Il suffit de supprimer tout le reste pour que la position passe par toutes les constructions if , et ne s'arrête pas au premier si elle retourne true . Ensuite, le dernier autre (celui qui n'est pas else if ) est changé en if, qui vérifie que la position a déjà été ajoutée en tant que mur et, dans le cas contraire, l'ajoute en tant que sol.


Incroyable, nous avons presque fini ici. Nous avons maintenant un élément complètement nouveau, créé au bon endroit, mais il est le même que notre première pièce: il est complètement clos de murs. Cela signifie que le joueur ne pourra pas accéder à ce nouvel endroit. Autrement dit, nous devons convertir un point sur le mur (qui, comme nous le rappelons, est stocké dans une variable de type Vector2Int) et le point correspondant sur le mur d'un nouvel élément dans Floor. Mais seulement lorsque l'élément n'est pas la première pièce.


Ce morceau de code vérifie si le nouvel élément est la première pièce. Sinon, il convertit la dernière position du mur au sol, puis vérifie la direction dans laquelle le mur regarde afin de vérifier quelle tuile du nouvel élément doit se transformer en sol.

Nous avons atteint la dernière partie de la fonction GenerateFeature (). Il contient déjà des lignes qui ajoutent des informations sur l'élément créé par la fonction.


Ici, nous devons changer quelque chose. Premièrement, le type d'élément n'est pas toujours égal à Room. Heureusement, la variable requise est transmise à la fonction en tant que paramètre, à savoir la chaîne de type. Remplaçons simplement ici "Room" par type.


Bien. Maintenant, pour que l'algorithme générant tous les éléments du jeu fonctionne correctement, nous devons ajouter de nouvelles données ici. A savoir, un int qui compte le nombre d'éléments créés et une liste de tous les éléments créés. Nous montons à l'endroit où nous déclarons toutes les variables et déclarons un int avec le nom countFeatures, ainsi qu'une liste d'éléments avec le nom allFeatures. La liste de tous les éléments doit être publique et le compteur int peut être privé.


Revenons maintenant à la fonction GenerateFeature () et ajoutez quelques lignes à la fin: incrémenter la variable countFeatures et ajouter un nouvel élément à la liste allFeatures.


Ainsi, notre GenerateFeature () est presque terminée. Plus tard, nous devrons y retourner pour remplir la fonction CheckIfHasSpace vide, mais nous devons d'abord la créer. C’est ce que nous allons faire maintenant.

Étape 8 - vérifier s'il y a une place


Créons maintenant une nouvelle fonction juste après la fin de la fonction GenerateFeature (). Elle a besoin de deux arguments: la position à laquelle l'élément commence et la position à laquelle il se termine. Vous pouvez utiliser deux variables Vector2Int comme elles. La fonction doit renvoyer une valeur booléenne afin qu'elle puisse être utilisée dans if pour vérifier l'espace.


Il est souligné en rouge, car jusqu'à présent il n'a rien retourné. Bientôt, nous allons le réparer, mais pour l'instant nous n'y ferons pas attention. Dans cette fonction, nous allons parcourir toutes les positions entre le début et la fin de l'élément et vérifier si la position actuelle dans MapManager.map est nulle ou si quelque chose est déjà là. S'il y a quelque chose, alors nous arrêtons la fonction et retournons false. Sinon, continuez. Si la fonction atteint la fin de la boucle sans rencontrer les emplacements remplis, retournez true.

De plus, avant de vérifier la position pour null, nous avons besoin d'une ligne pour vérifier si la position est dans la carte. Parce que sinon, nous pouvons obtenir une erreur d'index de tableau et un plantage du jeu.


Bien. Revenons maintenant à l'endroit où nous insérons cette fonction à l'intérieur de la fonction GenerateFeature (). Nous devons corriger cet appel car il ne transmet pas les arguments nécessaires.

Ici, nous voulons insérer une instruction if pour vérifier s'il y a suffisamment d'espace pour l'élément. Si le résultat est faux, nous terminons la fonction sans insérer un nouvel élément dans MapManager.map.


Nous devons passer les arguments requis, c'est-à-dire deux variables Vector2Int. Avec le premier, tout est simple, c'est la position avec les coordonnées x et y du point de départ de l'élément.


La seconde est plus difficile, mais pas de beaucoup. Il s'agit du point de départ plus la hauteur pour y et la largeur pour x, en soustrayant 1 des deux (car le début a déjà été pris en compte).


Passons maintenant à l'étape suivante: créer un algorithme pour appeler la fonction GenerateFeature ().

Étape 9 - Éléments générés par l'appel


Retour à la fonction GenerateDungeon () créée dans la partie précédente de l'article. Maintenant, cela devrait ressembler à ceci:


L'appel à FirstRoom () est souligné en rouge car nous avons changé le nom de cette fonction. Appelons donc la première génération de pièces.


Nous avons passé les arguments nécessaires: "Room" comme type, car la première salle sera toujours Room, new Wall (), car la première salle ne sera pas créée à partir d'une autre, nous passons donc null, et c'est tout à fait normal. Au lieu de nouveau Wall (), vous pouvez remplacer null , c'est une question de préférence personnelle. Le dernier argument détermine si le nouvel élément est la première pièce, donc dans notre cas, nous passons vrai .

Nous arrivons maintenant au point principal. Nous utilisons une boucle for qui s'exécutera 500 fois - oui, nous allons essayer d'ajouter des éléments 500 fois. Mais si le nombre d'éléments créés (variable countFeatures) est égal au nombre maximal spécifié d'éléments (variable maxFeatures), alors nous interrompons ce cycle.


La première étape de cette boucle consiste à déclarer l'élément à partir duquel le nouvel élément sera créé. Si nous n'avons créé qu'un seul élément (la première salle), ce sera l'original. Sinon, nous sélectionnons au hasard l'un des éléments déjà créés.


Nous allons maintenant choisir le mur de cet élément qui sera utilisé pour créer le nouvel élément.


Veuillez noter que nous n'avons pas encore cette fonction ChoseWall (). Écrivons-le rapidement. Descendez jusqu'à la fin de la fonction et créez-la. Il doit renvoyer un mur et utiliser un élément comme argument pour que la fonction puisse sélectionner le mur de cet élément.


Je l'ai créé entre les fonctions CheckIfHasSpace () et DrawMap (). Notez que si vous travaillez dans Visual Studio, qui est installé avec Unity, vous pouvez utiliser les champs - / + sur la gauche pour réduire / développer des parties du code pour simplifier le travail.

Dans cette fonction, nous trouverons le mur à partir duquel l'élément n'a pas encore été créé. Parfois, nous obtiendrons des éléments avec un ou plusieurs murs auxquels d'autres éléments sont déjà attachés, nous devons donc vérifier encore et encore si l'un des murs aléatoires est libre. Pour ce faire, nous utilisons une boucle for répétée dix fois - si après ces dix fois aucun mur libre n'est trouvé, la fonction retourne null.


Revenons maintenant à la fonction GenerateDungeon () et passez l'élément d'origine en tant que paramètre à la fonction ChoseWall ().


La ligne if (wall == null) continue;signifie que si la fonction de recherche de mur a renvoyé false, alors l'élément d'origine ne peut pas générer un nouvel élément, donc la fonction continuera le cycle, c'est-à-dire qu'elle ne pourrait pas créer un nouvel élément et passera à l'itération suivante du cycle.

Maintenant, nous devons sélectionner le type pour l'élément suivant. Si l'élément source est une pièce, alors le suivant doit être un couloir (nous ne voulons pas que la pièce mène directement à une autre pièce sans couloir entre elles). Mais s'il s'agit d'un couloir, nous devons créer la probabilité qu'un autre couloir ou une autre pièce soit le prochain.


Bien. Il nous suffit maintenant d'appeler la fonction GenerateFeature (), en lui passant le mur et en tapant comme paramètres.


Enfin, accédez à l'inspecteur Unity, sélectionnez l'objet GameManager et modifiez les valeurs comme suit:


Si vous cliquez maintenant sur le bouton de lecture, vous verrez déjà les résultats!


Comme je l'ai dit, ce n'est pas le meilleur donjon. Nous avons eu beaucoup d'impasses. Mais il est entièrement fonctionnel et garantit que vous n'aurez pas de pièce non connectée à une autre.

J'espère que tu as aimé! Dans le prochain post, nous allons créer un joueur qui se déplacera à travers le donjon, puis nous transformerons la carte d'ASCII en sprite.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Post3 : MonoBehaviour {
    public int mapWidth;
    public int mapHeight;

    public int widthMinRoom;
    public int widthMaxRoom;
    public int heightMinRoom;
    public int heightMaxRoom;

    public int minCorridorLength;
    public int maxCorridorLength;
    public int maxFeatures;
    int countFeatures;

    public bool isASCII;

    public List<Feature> allFeatures;

    public void InitializeDungeon() {
        MapManager.map = new Tile[mapWidth, mapHeight];
    }

    public void GenerateDungeon() {
        GenerateFeature("Room", new Wall(), true);

        for (int i = 0; i < 500; i++) {
            Feature originFeature;

            if (allFeatures.Count == 1) {
                originFeature = allFeatures[0];
            }
            else {
                originFeature = allFeatures[Random.Range(0, allFeatures.Count - 1)];
            }

            Wall wall = ChoseWall(originFeature);
            if (wall == null) continue;

            string type;

            if (originFeature.type == "Room") {
                type = "Corridor";
            }
            else {
                if (Random.Range(0, 100) < 90) {
                    type = "Room";
                }
                else {
                    type = "Corridor";
                }
            }

            GenerateFeature(type, wall);

            if (countFeatures >= maxFeatures) break;
        }

        DrawMap(isASCII);
    }

    void GenerateFeature(string type, Wall wall, bool isFirst = false) {
        Feature room = new Feature();
        room.positions = new List<Vector2Int>();

        int roomWidth = 0;
        int roomHeight = 0;

        if (type == "Room") {
            roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
            roomHeight = Random.Range(heightMinRoom, heightMaxRoom);
        }
        else {
            switch (wall.direction) {
                case "South":
                    roomWidth = 3;
                    roomHeight = Random.Range(minCorridorLength, maxCorridorLength);
                    break;
                case "North":
                    roomWidth = 3;
                    roomHeight = Random.Range(minCorridorLength, maxCorridorLength);
                    break;
                case "West":
                    roomWidth = Random.Range(minCorridorLength, maxCorridorLength);
                    roomHeight = 3;
                    break;
                case "East":
                    roomWidth = Random.Range(minCorridorLength, maxCorridorLength);
                    roomHeight = 3;
                    break;

            }
        }

        int xStartingPoint;
        int yStartingPoint;

        if (isFirst) {
            xStartingPoint = mapWidth / 2;
            yStartingPoint = mapHeight / 2;
        }
        else {
            int id;
            if (wall.positions.Count == 3) id = 1;
            else id = Random.Range(1, wall.positions.Count - 2);

            xStartingPoint = wall.positions[id].x;
            yStartingPoint = wall.positions[id].y;
        }

        Vector2Int lastWallPosition = new Vector2Int(xStartingPoint, yStartingPoint);

        if (isFirst) {
            xStartingPoint -= Random.Range(1, roomWidth);
            yStartingPoint -= Random.Range(1, roomHeight);
        }
        else {
            switch (wall.direction) {
                case "South":
                    if (type == "Room") xStartingPoint -= Random.Range(1, roomWidth - 2);
                    else xStartingPoint--;
                    yStartingPoint -= Random.Range(1, roomHeight - 2);
                    break;
                case "North":
                    if (type == "Room") xStartingPoint -= Random.Range(1, roomWidth - 2);
                    else xStartingPoint--;
                    yStartingPoint ++;
                    break;
                case "West":
                    xStartingPoint -= roomWidth;
                    if (type == "Room") yStartingPoint -= Random.Range(1, roomHeight - 2);
                    else yStartingPoint--;
                    break;
                case "East":
                    xStartingPoint++;
                    if (type == "Room") yStartingPoint -= Random.Range(1, roomHeight - 2);
                    else yStartingPoint--;
                    break;
            }
        }

         if (!CheckIfHasSpace(new Vector2Int(xStartingPoint, yStartingPoint), new Vector2Int(xStartingPoint + roomWidth - 1, yStartingPoint + roomHeight - 1))) {
            return;
        }

        room.walls = new Wall[4];

        for (int i = 0; i < room.walls.Length; i++) {
            room.walls[i] = new Wall();
            room.walls[i].positions = new List<Vector2Int>();
            room.walls[i].length = 0;

            switch (i) {
                case 0:
                    room.walls[i].direction = "South";
                    break;
                case 1:
                    room.walls[i].direction = "North";
                    break;
                case 2:
                    room.walls[i].direction = "West";
                    break;
                case 3:
                    room.walls[i].direction = "East";
                    break;
            }
        }

        for (int y = 0; y < roomHeight; y++) {
            for (int x = 0; x < roomWidth; x++) {
                Vector2Int position = new Vector2Int();
                position.x = xStartingPoint + x;
                position.y = yStartingPoint + y;

                room.positions.Add(position);

                MapManager.map[position.x, position.y] = new Tile();
                MapManager.map[position.x, position.y].xPosition = position.x;
                MapManager.map[position.x, position.y].yPosition = position.y;

                if (y == 0) {
                    room.walls[0].positions.Add(position);
                    room.walls[0].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (y == (roomHeight - 1)) {
                    room.walls[1].positions.Add(position);
                    room.walls[1].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (x == 0) {
                    room.walls[2].positions.Add(position);
                    room.walls[2].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (x == (roomWidth - 1)) {
                    room.walls[3].positions.Add(position);
                    room.walls[3].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                if (MapManager.map[position.x, position.y].type != "Wall") {
                    MapManager.map[position.x, position.y].type = "Floor";
                }
            }
        }

        if (!isFirst) {
            MapManager.map[lastWallPosition.x, lastWallPosition.y].type = "Floor";
            switch (wall.direction) {
                case "South":
                    MapManager.map[lastWallPosition.x, lastWallPosition.y - 1].type = "Floor";
                    break;
                case "North":
                    MapManager.map[lastWallPosition.x, lastWallPosition.y + 1].type = "Floor";
                    break;
                case "West":
                    MapManager.map[lastWallPosition.x - 1, lastWallPosition.y].type = "Floor";
                    break;
                case "East":
                    MapManager.map[lastWallPosition.x + 1, lastWallPosition.y].type = "Floor";
                    break;
            }
        }

        room.width = roomWidth;
        room.height = roomHeight;
        room.type = type;
        allFeatures.Add(room);
        countFeatures++;
    }

    bool CheckIfHasSpace(Vector2Int start, Vector2Int end) {
        for (int y = start.y; y <= end.y; y++) {
            for (int x = start.x; x <= end.x; x++) {
                if (x < 0 || y < 0 || x >= mapWidth || y >= mapHeight) return false;
                if (MapManager.map != null) return false;
            }
        }

        return true;
    }

    Wall ChoseWall(Feature feature) {
        for (int i = 0; i < 10; i++) {
            int id = Random.Range(0, 100) / 25;
            if (!feature.walls[id].hasFeature) {
                return feature.walls[id];
            }
        }
        return null;
    }

    void DrawMap(bool isASCII) {
        if (isASCII) {
            Text screen = GameObject.Find("ASCIITest").GetComponent<Text>();

            string asciiMap = "";

            for (int y = (mapHeight - 1); y >= 0; y--) {
                for (int x = 0; x < mapWidth; x++) {
                    if (MapManager.map[x, y] != null) {
                        switch (MapManager.map[x, y].type) {
                            case "Wall":
                                asciiMap += "#";
                                break;
                            case "Floor":
                                asciiMap += ".";
                                break;
                        }
                    }
                    else {
                        asciiMap += " ";
                    }

                    if (x == (mapWidth - 1)) {
                        asciiMap += "\n";
                    }
                }
            }

            screen.text = asciiMap;
        }
    }
}

All Articles