Criando roguelike no Unity a partir do zero: gerador de masmorras

imagem

Desta vez, mergulharemos na implementação do algoritmo do gerador de masmorras. No último artigo, criamos a primeira sala e agora vamos gerar o restante do nível da masmorra.

Mas antes de começarmos, eu gostaria de corrigir um erro de uma postagem anterior. De fato, nas últimas semanas, aprendi algo novo, e é por isso que parte do meu trabalho está desatualizada, e quero falar sobre isso.

Lembra da classe Position que criamos? De fato, o Unity já possui uma classe interna que executa exatamente as mesmas funções, mas com um controle um pouco melhor - é mais fácil declarar e processar. Essa classe é chamada Vector2Int. Portanto, antes de iniciar, removeremos a classe Position do MapManager.cs e substituiremos cada variável Position pela variável Vector2Int.


O mesmo precisa ser feito em vários lugares no script DungeonGenerator.cs. Agora vamos ao resto do algoritmo.

Etapa 7 - geração de sala / salão


Começaremos com uma pequena alteração na função FirstRoom () criada na última vez. Em vez de criar outra função para gerar todos os outros elementos do mapa e duplicar um monte de código, simplesmente transformamos essa função, transformando-a em um GenerateFeature () generalizado. Portanto, altere o nome de FirstRoom para GenerateFeature.

Agora precisaremos passar parâmetros para esta função. Primeiro de tudo, você precisa saber qual função ela gera - uma sala ou um corredor. Podemos apenas passar uma string chamada type. A seguir, a função precisa conhecer o ponto de partida do elemento, ou seja, de qual parede ele vem (porque sempre criamos um novo elemento a partir da parede do elemento antigo) e, para isso, passar como argumento Wall é suficiente. Por fim, a primeira sala a ser criada possui características especiais; portanto, precisamos de uma variável bool opcional que informe se o item é a primeira sala. Por padrão, é falso: bool isFirst = false. Portanto, o título da função mudará disso:


nisto:


Bem. O próximo passo é alterar a maneira como você calcula a largura e a altura do elemento. Enquanto os calculamos, obtendo um valor aleatório entre os valores mínimo e máximo da altura e largura das salas - isso é ideal para salas, mas não funciona para corredores. Até agora, temos o seguinte:


Mas os corredores terão um tamanho constante de 3 em largura ou altura, dependendo da orientação. Portanto, precisamos verificar qual é o elemento - uma sala ou um corredor e, em seguida, executar os cálculos apropriados.


Assim. verificamos se o item é uma sala. Se sim, então fazemos o mesmo de antes
- obtemos um número aleatório no intervalo entre min e max de altura e largura. Mas agora , do mesmo modo, se você precisar fazer algo um pouco diferente. Precisamos verificar a orientação do corredor. Felizmente, ao gerar um muro, salvamos informações sobre para qual direção ele é direcionado e, portanto, usamos para obter a orientação do corredor.


Mas ainda não declaramos a variável minCorridorLength. Você precisa voltar para as declarações de variáveis ​​e declará-las, logo acima de maxCorridorLength.


Agora, de volta às nossas instruções de troca condicional. O que estamos fazendo aqui: obtemos o valor da direção do muro, ou seja, para onde o muro está olhando, de onde o corredor passará. A direção pode ter apenas quatro valores possíveis: sul, norte, oeste e leste. No sul e norte, o corredor terá uma largura de 3 (duas paredes e um piso no meio) e uma altura variável (comprimento). Para oeste e leste, tudo será o contrário: a altura será constantemente igual a 3 e a largura terá um comprimento variável. Então, vamos fazê-lo.


Uau. E foi aí que acabamos dimensionando o novo item. Agora você precisa decidir onde colocá-lo. Colocamos a primeira sala em um local aleatório dentro dos valores-limite relativos ao centro do mapa.


Mas para todos os outros elementos, isso não funcionará. Eles devem começar ao lado do ponto aleatório na parede a partir da qual o elemento é gerado. Então, vamos mudar o código. Primeiro, precisamos verificar se o elemento é a primeira sala. Se esta é a primeira sala, definimos os pontos de partida da mesma maneira que antes - como metade da largura e altura do mapa.


Caso contrário, se o elemento não for a primeira sala, obteremos um ponto aleatório na parede a partir da qual o elemento é gerado. Primeiro, devemos verificar se a parede tem um tamanho de 3 (isso significa que é o ponto final do corredor) e, nesse caso, o ponto do meio será sempre selecionado, ou seja, o índice 1 da matriz da parede (com 3 elementos, a matriz terá índices 0, 1, 2). Mas se o tamanho não for igual a 3 (a parede não é o ponto final do corredor), tomaremos um ponto aleatório no intervalo entre o ponto 1 e o comprimento da parede menos 2. Isso é necessário para evitar passagens criadas no canto. Ou seja, por exemplo, em uma parede com um comprimento de 6, excluímos os índices 0 e 5 (primeiro e último) e selecionamos um ponto aleatório entre os pontos 1, 2, 3 e 4.


Agora temos a posição do ponto na parede em que um novo elemento será criado. Mas não podemos simplesmente começar a gerar um elemento a partir daí, pois assim será bloqueado por paredes já colocadas. Também é importante observar que o elemento começa a ser gerado a partir do canto inferior esquerdo e, em seguida, o incremento é realizado para a direita e para cima, portanto, devemos definir a posição inicial em locais diferentes, dependendo da direção em que a parede estiver olhando. Além disso, a primeira coluna x e a primeira linha y serão paredes, e se iniciarmos um novo elemento próximo a um ponto na parede, podemos criar um corredor que termina em um canto da sala e não em um local adequado na parede.

Portanto, se a parede estiver direcionada para o norte, é necessário que o elemento comece em uma posição ao norte no eixo y, mas em um número aleatório de posições a oeste no eixo x, no intervalo de 1 à largura da sala-2. Na direção sul, o eixo x age da mesma forma, mas a posição inicial no eixo y é a posição do ponto na parede menos a altura da sala. As muralhas ocidental e oriental seguem a mesma lógica, apenas com eixos invertidos.

Mas antes de fazer tudo isso, precisamos salvar a posição do ponto da parede na variável Vector2Int para que possamos manipulá-lo mais tarde.


Ótimo. Vamos fazer isso.


Então, geramos um elemento com o tamanho e a posição, e o próximo passo é colocar o elemento no mapa. Mas primeiro, precisamos descobrir se realmente há espaço no mapa para esse elemento nessa posição. Por enquanto, chamamos a função CheckIfHasSpace (). Será destacado em vermelho, porque ainda não o implementamos. Faremos isso logo após concluir o que precisa ser feito aqui na função GenerateFeature (). Portanto, ignore o sublinhado vermelho e continue.


Na próxima parte, paredes são criadas. Até tocá-lo, com exceção do fragmento no segundo loop for .


Enquanto escrevia este post, notei que essas construções if-else estão completamente erradas. Por exemplo, algumas paredes nelas receberão um comprimento de 1. Isso acontece porque quando a posição deve ser adicionada, digamos, à parede norte, se ela estava na esquina com a parede leste, não será adicionada à parede leste, como deveria. Isso causou bugs irritantes no algoritmo de geração. Vamos eliminá-los.

Consertá-los é bem simples. É suficiente excluir todo o resto para que a posição passe por todas as construções if e não pare no início, se retornou true . Em seguida, o último mais (aquele que não é mais se ) é alterado para se, que verifica se a posição já foi adicionada como Mural e, se não estiver, a adiciona como Piso.


Incrível, estamos quase terminando aqui. Agora temos um elemento completamente novo, criado no lugar certo, mas é o mesmo que o nosso primeiro quarto: é completamente cercado por paredes. Isso significa que o jogador não poderá chegar a este novo local. Ou seja, precisamos converter um ponto na parede (que, como lembramos, é armazenado em uma variável do tipo Vector2Int) e o ponto correspondente na parede de um novo elemento no Floor. Mas somente quando o elemento não é o primeiro quarto.


Este trecho de código verifica se o novo item é o primeiro quarto. Caso contrário, ele converte a última posição da parede no chão e, em seguida, verifica a direção que a parede está olhando para verificar qual ladrilho do novo elemento deve se transformar no chão.

Atingimos a última parte da função GenerateFeature (). Ele já possui linhas que adicionam informações sobre o elemento que a função cria.


Aqui precisamos mudar alguma coisa. Em primeiro lugar, o tipo de elemento nem sempre é igual a Room. Felizmente, a variável necessária é passada para a função como um parâmetro, ou seja, o tipo string. Então, vamos substituir "Room" aqui pelo tipo


Boa. Agora, para que o algoritmo que gera todos os elementos do jogo funcione corretamente, precisamos adicionar novos dados aqui. Ou seja, um int que conta o número de itens criados e uma lista de todos os itens criados. Vamos até o local em que declaramos todas as variáveis ​​e declaramos um int com o nome countFeatures, bem como uma Lista de elementos com o nome allFeatures. A lista de todos os elementos deve ser pública e o contador int pode ser privado.


Agora, de volta à função GenerateFeature () e adicione algumas linhas ao final: incrementando a variável countFeatures e adicionando um novo elemento à lista allFeatures.


Portanto, nosso GenerateFeature () está quase completo. Mais tarde, precisaremos retornar a ela para preencher a função CheckIfHasSpace vazia, mas primeiro precisamos criá-la. É isso que vamos fazer agora.

Etapa 8 - verifique se há um local


Agora vamos criar uma nova função logo após a conclusão da função GenerateFeature (). Ela precisa de dois argumentos: a posição em que o elemento começa e a posição em que termina. Você pode usar duas variáveis ​​Vector2Int como elas. A função deve retornar um valor bool para que ele possa ser usado em se verificar se há espaço.


Ele está sublinhado em vermelho, porque até agora não retornou nada. Em breve iremos consertar, mas por enquanto não prestaremos atenção. Nesta função, percorreremos todas as posições entre o início e o final do elemento e verificaremos se a posição atual no MapManager.map é nula ou se já existe alguma coisa. Se houver algo lá, paramos a função e retornamos false. Caso contrário, continue. Se a função chegar ao final do loop sem atender aos locais preenchidos, retorne true.

Além disso, antes de verificar a posição como nula, precisamos de uma linha para verificar se a posição está dentro do mapa. Porque, caso contrário, podemos receber um erro de índice de matriz e uma falha no jogo.


Bem. Agora, de volta ao local em que inserimos essa função dentro da função GenerateFeature (). Precisamos consertar essa chamada porque ela não passa os argumentos necessários.

Aqui queremos inserir uma instrução if para verificar se há espaço suficiente para o elemento. Se o resultado for falso, encerramos a função sem inserir um novo elemento no MapManager.map.


Precisamos passar os argumentos necessários, ou seja, duas variáveis ​​Vector2Int. Com o primeiro, tudo é simples, esta é a posição com as coordenadas x e y do ponto inicial do elemento.


O segundo é mais difícil, mas não muito. Este é o ponto inicial mais a altura para ye largura para x, subtraindo 1 de ambos (porque o início já foi levado em consideração).


Agora vamos para a próxima etapa - criando um algoritmo para chamar a função GenerateFeature ().

Etapa 9 - elementos gerados por chamada


Voltar para a função GenerateDungeon () criada na parte anterior do artigo. Agora deve ficar assim:


A chamada para FirstRoom () está sublinhada em vermelho porque alteramos o nome dessa função. Então, vamos chamar a primeira geração de quarto.


Passamos os argumentos necessários: “Room” como tipo, porque o primeiro quarto sempre será Room, novo Wall (), porque o primeiro quarto não será criado a partir de nenhum outro, apenas passamos nulos, e isso é normal. Em vez de novo Wall (), você pode substituir null , isso é uma questão de preferência pessoal. O último argumento determina se o novo elemento é a primeira sala; portanto, no nosso caso, passamos a verdade .

Agora chegamos ao ponto principal. Usamos um loop for que será executado 500 vezes - sim, tentaremos adicionar elementos 500 vezes. Mas se o número de elementos criados (variável countFeatures) for igual ao número máximo especificado de elementos (variável maxFeatures), interromperemos esse ciclo.


A primeira etapa desse loop é declarar o elemento a partir do qual o novo elemento será criado. Se tivermos criado apenas um elemento (a primeira sala), será o original. Caso contrário, selecionamos aleatoriamente um dos elementos já criados.


Agora vamos escolher qual parede desse elemento será usada para criar o novo elemento.


Observe que ainda não temos essa função ChoseWall (). Vamos escrever rapidamente. Vá até o final da função e crie-a. Ele deve retornar uma parede e usar um elemento como argumento, para que a função possa selecionar a parede desse elemento.


Eu o criei entre as funções CheckIfHasSpace () e DrawMap (). Observe que, se você estiver trabalhando no Visual Studio, instalado com o Unity, poderá usar os campos - / + à esquerda para recolher / expandir partes do código para simplificar o trabalho.

Nesta função, encontraremos a parede a partir da qual o elemento ainda não foi criado. Às vezes, obteremos elementos com uma ou mais paredes das quais outros elementos já estão anexados; portanto, precisamos verificar repetidamente se alguma das paredes aleatórias está livre. Para fazer isso, usamos um loop for repetido dez vezes - se após essas dez vezes uma parede livre não for encontrada, a função retornará nulo.


Agora, volte para a função GenerateDungeon () e passe o elemento original como parâmetro para a função ChoseWall ().


A linha if (wall == null) continue;significa que, se a função de pesquisa de parede retornou falsa, o elemento original não pode gerar um novo elemento; portanto, a função continuará o ciclo, ou seja, não poderá criar um novo elemento e prosseguirá para a próxima iteração do ciclo.

Agora precisamos selecionar o tipo para o próximo item. Se o elemento de origem for uma sala, o próximo deverá ser um corredor (não queremos que a sala conduza diretamente para outra sala sem um corredor entre eles). Mas se esse for um corredor, precisamos criar a probabilidade de que outro corredor ou sala seja o próximo.


Bem. Agora só precisamos chamar a função GenerateFeature (), passando a parede e digite como parâmetros.


Por fim, vá ao inspetor do Unity, selecione o objeto GameManager e altere os valores para o seguinte:


Se você agora clicar no botão play, já verá os resultados!


Como eu disse, essa não é a melhor masmorra. Temos muitos becos sem saída. Mas é totalmente funcional e garante que você não terá uma sala que não esteja conectada a nenhuma outra.

Espero que tenha gostado! No próximo post, criaremos um jogador que passará pela masmorra e depois transformaremos o mapa de ASCII em 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