Creando roguelike en Unity desde cero: generador de mazmorras

imagen

Esta vez nos sumergiremos en la implementación del algoritmo del generador de mazmorras. En el último artículo, creamos la primera sala, y ahora generaremos el resto del nivel de mazmorra.

Pero antes de comenzar, me gustaría corregir un error de una publicación anterior. De hecho, en las últimas semanas he aprendido algo nuevo, por lo que parte del trabajo que he realizado está desactualizado y quiero hablar sobre ello.

¿Recuerdas la clase de posición que creamos? De hecho, Unity ya tiene una clase incorporada que realiza exactamente las mismas funciones, pero con un control ligeramente mejor: es más fácil de declarar y procesar. Esta clase se llama Vector2Int. Por lo tanto, antes de comenzar, eliminaremos la clase Position de MapManager.cs y reemplazaremos cada variable de Position con la variable Vector2Int.


Lo mismo debe hacerse en varios lugares en el script DungeonGenerator.cs. Ahora vamos al resto del algoritmo.

Etapa 7 - generación de sala / sala


Comenzaremos con un pequeño cambio en la función FirstRoom () creada la última vez. En lugar de crear otra función para generar todos los demás elementos del mapa y duplicar un montón de código, simplemente transformamos esta función, convirtiéndola en una GenerateFeature () generalizada. Por lo tanto, cambie el nombre de FirstRoom a GenerateFeature.

Ahora necesitaremos pasar parámetros a esta función. En primer lugar, debe saber qué función genera: una habitación o un pasillo. Podemos pasar una cadena llamada tipo. Luego, la función necesita conocer el punto de partida del elemento, es decir, de qué pared proviene (porque siempre creamos un nuevo elemento a partir de la pared del elemento más antiguo), y para esto, pasar como argumento de Wall es suficiente. Finalmente, la primera sala que se creará tiene características especiales, por lo que necesitamos una variable bool opcional que indique si el elemento es la primera sala. Por defecto, es falso: bool isFirst = false. Entonces el título de la función cambiará de esto:


en este:


Multa. El siguiente paso es cambiar la forma de calcular el ancho y la altura del elemento. Mientras los calculamos, obtenemos un valor aleatorio entre los valores mínimo y máximo de la altura y el ancho de las habitaciones; esto es ideal para las habitaciones, pero no funcionará para los corredores. Entonces, hasta ahora tenemos lo siguiente:


Pero los corredores tendrán un tamaño constante de 3 de ancho o alto, dependiendo de la orientación. Por lo tanto, debemos verificar cuál es el elemento: una habitación o un corredor, y luego realizar los cálculos apropiados.


Entonces. Verificamos si el artículo es una habitación. En caso afirmativo, hacemos lo mismo que antes
: obtenemos un número aleatorio en el intervalo entre mínimo y máximo de altura y anchura. Pero ahora en lo mismo si necesita hacer algo un poco diferente. Necesitamos verificar la orientación del corredor. Afortunadamente, al generar un muro, guardamos información sobre en qué dirección se dirige, por lo que lo usamos para obtener la orientación del corredor.


Pero aún no hemos declarado la variable minCorridorLength. Debe volver a las declaraciones de variables y declararlo, justo encima de maxCorridorLength.


Ahora volvamos a nuestras declaraciones de cambio condicional. Lo que estamos haciendo aquí: obtenemos el valor de la dirección de la pared, es decir, dónde está mirando la pared, desde donde irá el corredor. La dirección solo puede tener cuatro valores posibles: Sur, Norte, Oeste y Este. En el caso del sur y el norte, el corredor tendrá un ancho de 3 (dos paredes y un piso en el medio) y una altura variable (longitud). Para el oeste y el este, todo será al revés: la altura será constantemente igual a 3, y el ancho tendrá una longitud variable. Hagamoslo.


Guau. Y ahí es donde terminamos dimensionando el nuevo elemento. Ahora debe decidir dónde colocarlo. Colocamos la primera habitación en un lugar aleatorio dentro de los valores de umbral relativos al centro del mapa.


Pero para todos los demás elementos, esto no funcionará. Deben comenzar al lado del punto aleatorio en la pared desde el cual se genera el elemento. Entonces cambiemos el código. Primero, debemos verificar si el elemento es la primera habitación. Si esta es la primera habitación, definimos los puntos de partida de la misma manera que antes, como la mitad del ancho y la altura del mapa.


De lo contrario, si el elemento no es la primera habitación, obtenemos un punto aleatorio en la pared desde el cual se genera el elemento. Primero, debemos verificar si el muro tiene un tamaño de 3 (esto significará que es el punto final del corredor), y si es así, entonces el punto medio siempre se seleccionará, es decir, el índice 1 del conjunto de muros (con 3 elementos, el conjunto tiene índices 0, 1, 2). Pero si el tamaño no es igual a 3 (el muro no es el punto final del corredor), entonces tomamos un punto aleatorio entre el punto 1 y la longitud del muro menos 2. Esto es necesario para evitar pasajes creados en la esquina. Es decir, por ejemplo, en una pared con una longitud de 6, excluimos los índices 0 y 5 (primero y último), y seleccionamos un punto aleatorio entre los puntos 1, 2, 3 y 4.


Ahora tenemos la posición del punto en la pared en el que se creará un nuevo elemento. Pero no podemos simplemente comenzar a generar un elemento a partir de ahí, porque de esta manera será bloqueado por paredes ya colocadas. También es importante tener en cuenta que el elemento comienza a generarse desde su esquina inferior izquierda, y luego el incremento se realiza hacia la derecha y hacia arriba, por lo que debemos establecer la posición de inicio en diferentes lugares, dependiendo de la dirección en la que mira el muro. Además, la primera columna xy la primera fila y serán paredes, y si comenzamos un nuevo elemento justo al lado de un punto en la pared, podemos crear un corredor que termine en una esquina de la habitación y no en un lugar adecuado en la pared.

Entonces, si la pared se dirige hacia el norte, entonces es necesario que el elemento comience en una posición al norte en el eje y, pero en un número aleatorio de posiciones al oeste en el eje x, en el rango de 1 al ancho de la habitación-2. En la dirección sur, el eje x actúa igual, pero la posición inicial en el eje y es la posición del punto en la pared menos la altura de la habitación. Los muros occidental y oriental siguen la misma lógica, solo con ejes invertidos.

Pero antes de hacer todo esto, debemos guardar la posición del punto de muro en la variable Vector2Int para poder manipularlo más tarde.


Excelente. Vamos a hacer eso.


Entonces, generamos un elemento con el tamaño y la posición, y el siguiente paso es colocar el elemento en el mapa. Pero primero, necesitamos descubrir si realmente hay espacio en el mapa para este elemento en esta posición. Por ahora, solo llamamos a la función CheckIfHasSpace (). Se resaltará en rojo, porque aún no lo hemos implementado. Haremos esto justo después de terminar lo que se debe hacer aquí en la función GenerateFeature (). Por lo tanto, ignore el subrayado rojo y continúe.


En la siguiente parte, se crean muros. Hasta que lo toquemos, con la excepción del fragmento en el segundo bucle for .


Mientras escribía esta publicación, noté que estas construcciones if-else están completamente equivocadas. Por ejemplo, algunos muros en ellos recibirán una longitud de 1. Esto sucede porque cuando la posición se va a agregar, por ejemplo, al muro norte, entonces si estaba en la esquina con el muro este, no se agregará al muro este, como debería. Esto causó errores molestos en el algoritmo de generación. Eliminémoslos.

Arreglarlos es bastante simple. Es suficiente eliminar todo lo demás para que la posición pase por todas las construcciones if , y no se detenga en la primera si devuelve verdadero . Entonces el último más (el que no es otro si ) se cambia a si, que comprueba que la posición ya se ha agregado como Muro y, si no lo está, la agrega como Piso.


Increíble, casi hemos terminado aquí. Ahora tenemos un elemento completamente nuevo, creado en el lugar correcto, pero es lo mismo que nuestra primera habitación: está completamente cerrado por paredes. Esto significa que el jugador no podrá llegar a este nuevo lugar. Es decir, necesitamos convertir un punto en la pared (que, como recordamos, se almacena en una variable de tipo Vector2Int) y el punto correspondiente en la pared de un nuevo elemento en Floor. Pero solo cuando el elemento no es la primera habitación.


Este fragmento de código verifica si el nuevo elemento es la primera habitación. De lo contrario, convierte la última posición de la pared en el piso y luego verifica la dirección en la que mira la pared para verificar qué baldosa del nuevo elemento debe convertirse en el piso.

Hemos llegado a la última parte de la función GenerateFeature (). Ya tiene líneas que agregan información sobre el elemento que crea la función.


Aquí necesitamos cambiar algo. En primer lugar, el tipo de elemento no siempre es igual a Room. Afortunadamente, la variable requerida se pasa a la función como un parámetro, es decir, la cadena de tipo. Así que vamos a reemplazar "Habitación" aquí con el tipo.


Bueno. Ahora, para que el algoritmo que genera todos los elementos del juego funcione correctamente, necesitamos agregar nuevos datos aquí. A saber, un int que cuenta el número de elementos creados y una lista de todos los elementos creados. Subimos al lugar donde declaramos todas las variables y declaramos un int con el nombre countFeatures, así como una Lista de elementos con el nombre allFeatures. La lista de todos los elementos debe ser pública y el contador int puede ser privado.


Ahora regrese a la función GenerateFeature () y agregue algunas líneas al final: incremente la variable countFeatures y agregue un nuevo elemento a la lista allFeatures.


Entonces, nuestro GenerateFeature () está casi completo. Más tarde, tendremos que volver a él para completar la función CheckIfHasSpace vacía, pero primero debemos crearla. Eso es lo que haremos ahora.

Etapa 8: verifique si hay un lugar


Ahora creemos una nueva función justo después de que se complete la función GenerateFeature (). Necesita dos argumentos: la posición en la que comienza el elemento y la posición en la que termina. Puede usar dos variables Vector2Int como ellas. La función debe devolver un valor bool para que pueda usarse si se busca espacio.


Está subrayado en rojo, porque hasta ahora no ha devuelto nada. Pronto lo arreglaremos, pero por ahora no prestaremos atención. En esta función, recorreremos todas las posiciones entre el principio y el final del elemento, y comprobaremos si la posición actual en MapManager.map es nula o si ya hay algo allí. Si hay algo allí, entonces detenemos la función y devolvemos falso. Si no, entonces continúa. Si la función llega al final del ciclo sin encontrar los lugares llenos, entonces devuelve verdadero.

Además, antes de verificar la posición para nulo, necesitamos una línea para verificar si la posición está dentro del mapa. Porque de lo contrario, podemos obtener un error de índice de matriz y un bloqueo del juego.


Multa. Ahora regrese al lugar donde insertamos esta función dentro de la función GenerateFeature (). Necesitamos arreglar esta llamada porque no pasa los argumentos necesarios.

Aquí queremos insertar una declaración if para verificar si hay suficiente espacio para el elemento. Si el resultado es falso, finalizamos la función sin insertar un nuevo elemento en MapManager.map.


Necesitamos pasar los argumentos requeridos, es decir, dos variables Vector2Int. Con el primero, todo es simple, esta es la posición con las coordenadas x e y del punto de inicio del elemento.


El segundo es más difícil, pero no mucho. Este es el punto de partida más la altura para y y el ancho para x, restando 1 de ambos (porque el inicio ya se ha tenido en cuenta).


Ahora pasemos al siguiente paso: crear un algoritmo para llamar a la función GenerateFeature ().

Etapa 9: elementos generados por la llamada


Volver a la función GenerateDungeon () creada en la parte anterior del artículo. Ahora debería verse así:


La llamada a FirstRoom () está subrayada en rojo porque cambiamos el nombre de esta función. Entonces llamemos a la primera generación de salas.


Pasamos los argumentos necesarios: "Room" como tipo, porque la primera habitación siempre será Room, new Wall (), porque la primera habitación no se creará de ninguna otra, por lo que simplemente pasamos nulo, y esto es bastante normal. En lugar de nuevo Wall (), puede sustituir nulo , esto es una cuestión de preferencia personal. El último argumento determina si el nuevo elemento es la primera habitación, por lo que en nuestro caso pasamos verdadero .

Ahora llegamos al punto principal. Usamos un bucle for que se ejecutará 500 veces; sí, intentaremos agregar elementos 500 veces. Pero si el número de elementos creados (variable countFeatures) es igual al número máximo especificado de elementos (variable maxFeatures), entonces interrumpimos este ciclo.


El primer paso en este ciclo es declarar el elemento a partir del cual se creará el nuevo elemento. Si hemos creado solo un elemento (la primera sala), entonces será el original. De lo contrario, seleccionamos aleatoriamente uno de los elementos ya creados.


Ahora elegiremos qué muro de este elemento se usará para crear el nuevo elemento.


Tenga en cuenta que todavía no tenemos esta función ChoseWall (). Escribámoslo rápido. Baja hasta el final de la función y créala. Debería devolver un muro y usar un elemento como argumento, para que la función pueda seleccionar el muro de este elemento.


Lo creé entre las funciones CheckIfHasSpace () y DrawMap (). Tenga en cuenta que si está trabajando en Visual Studio, que está instalado con Unity, puede usar los campos - / + a la izquierda para contraer / expandir partes del código para simplificar el trabajo.

En esta función encontraremos el muro desde el cual el elemento aún no se ha creado. A veces obtendremos elementos con uno o más muros de los cuales otros elementos ya están unidos, por lo que debemos verificar una y otra vez si alguno de los muros aleatorios está libre. Para hacer esto, usamos un bucle for repetido diez veces: si después de estas diez veces no se encuentra un muro libre, entonces la función devuelve nulo.


Ahora regrese a la función GenerateDungeon () y pase el elemento original como parámetro a la función ChoseWall ().


La línea if (wall == null) continue;significa que si la función de búsqueda de muro devuelve falso, entonces el elemento original no puede generar un nuevo elemento, por lo tanto, la función continuará el ciclo, es decir, no podría crear un nuevo elemento y pasará a la siguiente iteración del ciclo.

Ahora necesitamos seleccionar el tipo para el siguiente elemento. Si el elemento fuente es una habitación, la siguiente debe ser un pasillo (no queremos que la habitación conduzca directamente a otra habitación sin un pasillo entre ellos). Pero si este es un corredor, entonces debemos crear la probabilidad de que otro corredor o habitación sea el próximo.


Multa. Ahora solo necesitamos llamar a la función GenerateFeature (), pasarle la pared y escribir como parámetros.


Finalmente, vaya al inspector de Unity, seleccione el objeto GameManager y cambie los valores a lo siguiente:


Si ahora hace clic en el botón de reproducción, ¡ya verá los resultados!


Como dije, esta no es la mejor mazmorra. Tenemos muchos callejones sin salida. Pero es completamente funcional y garantiza que no tendrá una habitación que no esté conectada a ninguna otra.

¡Espero que lo hayan disfrutado! En la próxima publicación, crearemos un jugador que se moverá a través de la mazmorra, y luego convertiremos el mapa de 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