Como criar mapas aleatórios inspirado por Binding of Isaac na Unity

Geração procedural é um assunto que tenho bastante interesse. E em minhas pesquisas recentes sobre o tema encontrei um artigo no blog Boris the Brave e um vídeo do criador do jogo Binding of Isaac que explicam como funciona a geração de mapas no jogo. Inspirado por esses dois conteúdos decidi implementar minha versão do gerador de mapas usando a Unity. E nesse tutorial vou compartilhar como você pode fazer o mesmo.

Antes de começar

Para este tutorial estou usando a Unity 2021.3.4f1 lts. Mas você pode usar a versão que preferir uma vez que o projeto não requer nada que seja específico dessa versão. Aliás, você pode adaptar esse código para engine que quiser, já que o que importa aqui é a lógica. Prova disso é que o jogo original foi criado usando Flash.

Além disso, é necessário que você tenha pelo menos conhecimentos básicos da Unity e de programação, já que não entrarei em detalhes em conceitos básicos sobre ambos. Por exemplo, você precisa saber como criar GameObject, Prefabs, scripts, adicionar componentes, etc

Mesmo assim, caso encontre algum problema ou dificuldade, sinta-se à vontade para entrar em contato comigo.

Por fim, os arquivos do projeto estão disponíveis no GitHub. Os ícones que usei estão disponíveis aqui.

Geração do layout do mapa

Primeiramente, iremos criar um novo projeto na Unity usando o template para jogo 2D. 

Ao abrir o projeto criaremos uma pasta chamada “Scripts”, usaremos ela para guardar nossos scripts e manter o projeto organizado. 

Em seguida, vamos criar dois scripts dentro dessa pasta. Nomeie o primeiro arquivo como Room e o segundo como Map.

Agora, criaremos um novo GameObject na hierarquia, nomeie como Map, e adicione o script Map como um componente.

Abra o script Room no editor de texto de sua preferência. Eu vou usar o Visual Studio.

Com a classe Room aberta, podemos apagar os métodos Start e Update e os comentários que a Unity adiciona automaticamente quando criamos um script. Remova também a herança de MonoBehaviour. Essa classe é responsável por guardar informações que são relevantes durante a geração do mapa.

Essa é uma classe simples, nela temos apenas duas propriedades. A primeira chamada Position. Essa propriedade é usada para guardar a posição da sala, porém essa posição não corresponde diretamente com a posição da sala no mundo do jogo e sim na representação do mapa durante a geração, ainda assim, usaremos essa posição para calcular a posição da sala no mundo. A segunda propriedade chama-se IsSelected e representa se uma sala deve ser usada ou não no momento em que criarmos as salas no mundo. Também temos um construtor que recebe a posição da sala no mapa.

using UnityEngine;

public class Room
{
    public Room(Vector3Int position)
    {
        Position = position;
    }

    public Vector3Int Position { get; set; }
    public bool IsSelected { get; set; }
}

Agora abra o script Map, esse será o nosso script principal, ele será responsável por gerar o layout do mapa e criar as salas no mundo. 

Primeiramente, apague os métodos Start e Update, não iremos precisar deles, porém mantenha a herança de MonoBehaviour já que a classe é usada como um componente em um GameObject.

Com a classe vazia, iremos declarar três variáveis e marcar todas com o atributo SerializeField, dessa forma podemos editar seus valores diretamente no editor. A primeira variável será chamada de width e a segunda chamada de height. Elas são usadas para definir quantas salas teremos nas colunas e linhas do mapa. A terceira variável será chamada de numberOfRooms, e irá guardar o número de salas que o mapa deve ter.

Então, declaramos mais uma variável, chamada de map, dessa vez não iremos usar o atributo SerializeField. Essa variável é uma matriz (array) multidimensional que representa o nosso mapa e irá guardar as informações de todas as salas.

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

public class Map : MonoBehaviour
{
    [SerializeField]
    private int width = 10;
    [SerializeField]
    private int height = 10;
    [SerializeField]
    private int numberOfRooms = 10;

    private Room[,] map;
}

Agora vamos criar um método chamado CreateMapLayout. Este método é o responsável por definir o layout do mapa. A primeira coisa que temos que fazer é inicializar o campo map com as informações necessárias. Fazemos isso aqui para apagar qualquer informação que tenha sido gerada anteriormente.

Podemos escrever o código para isso diretamente aqui, mas vamos tentar manter a classe organizada, com esse intuito, vamos criar um método chamado InitializeMap. Porém ao invés de implementar o método imediatamente, deixaremos para fazer isso quando terminarmos de escrever o método CreateMapLayout. Isso ajuda a não perdermos o foco na lógica que estamos trabalhando no momento.

private void CreateMapLayout()
{
    InitializeMap();
}

Porém isso, irá gerar um erro, que no momento não afeta nada. Mas se você não gostar de ficar vendo o erro, podemos usar o Visual Studio para gerar um método de forma rápida e que irá lançar uma exceção caso seja executado. Para isso, clique com o botão direito do mouse em cima do nome do método, selecione a opção Ações Rápidas e Refatoração, em seguida na opção Gerar método. Dessa forma não perdemos nossa linha de pensamento e caso esqueçamos de implementar o método seremos lembrado com uma exceção.

Depois que inicializamos o mapa, vamos definir qual será nossa sala inicial, fazemos isso porque o algoritmo de busca usado para gerar o mapa precisa de um ponto de partida. Além disso, como estou tentando recriar a geração de mapa de Binding of Isaac, e segundo o artigo o jogo sempre usar a sala no meio do mapa para começar a geração, farei o mesmo, mas nada impede de escolher outra sala no mapa como ponto inicial.

Room startingRoom = map[width / 2, height / 2];
startingRoom.IsSelected = true;

Em seguida iremos criar uma fila de salas com a classe Queue e iremos usar o método Enqueue para adicionar a primeira sala a essa fila.

Também precisamos declarar uma variável chamada roomsCount, ela será usada para contar o número de salas que selecionamos. Essa contagem é importante para sabermos quando devemos parar de buscar por novas salas. Inicializamos ela com 1, já que sempre adicionamos a sala inicial ao mapa.

Queue<Room> roomsToVisit = new Queue<Room>();
roomsToVisit.Enqueue(startingRoom);

int roomsCount = 1;

Agora chegamos a parte principal desse método, que é o algoritmo de busca que irá gerar o mapa. Você pode ler mais sobre esse algoritmo na Wikipédia, ou neste artigo do blog Red Blob Games.

Iremos repetir esse bloco de código enquanto tivermos sala para serem visitadas na nossa fila. Por isso precisamos adicionar a sala inicial manualmente, porque caso contrário esse bloco while nunca seria executado.

A primeira coisa que precisamos fazer é pegar a primeira sala da nossa fila, para isso usamos o método Dequeue, isso fará com que a sala seja removida da fila. 

Em seguida precisamos encontrar todas as salas que são vizinhas da sala atual e adicionar em uma lista. Para isso iremos criar um novo método chamado GetNeighbors e vamos passar a sala atual como argumento. Como fizemos antes, vamos implementar esse método mais tarde.

while (roomsToVisit.Count > 0)
{
    Room currentRoom = roomsToVisit.Dequeue();
    List<Room> currentRoomNeighbors = GetNeighbors(currentRoom);
}

Com os vizinhos da sala atual selecionados, iremos iterar por todos eles usando a instrução foreach.

Agora precisamos fazer algumas checagens para saber se o vizinho da sala atual deve ser ou não selecionado.

A primeira checagem que faremos é saber se esse vizinho já foi selecionado, caso a propriedade IsSelected seja verdadeira usaremos a instrução continue para pular para o próximo vizinho.

foreach (Room neighbor in currentRoomNeighbors)
{
    if (neighbor.IsSelected)
    {
        continue;
    }
}

Caso contrário, iremos checar se o vizinho tem mais de uma sala vizinha selecionada, para isso criaremos um novo método chamado HasMoreThanOneNeighbor e passaremos o vizinho atual como argumento. Faça o que fizemos antes e deixe para implementar o método mais tarde.

Caso esse vizinho tenha mais de um vizinho selecionado, usaremos novamente a instrução continue. Essa checagem evita que existam ciclos no mapa, fazendo com que o mapa consista apenas de corredores.

if (HasMoreThanOneNeighbor(neighbor))
{
    continue;
}

A próxima checagem é para saber se já temos o número suficiente de salas selecionadas no mapa. Se sim usamos continue novamente.

bool hasEnoughRoom = roomsCount >= numberOfRooms;

if (hasEnoughRoom)
{
    continue;
}

E por último, temos uma chance de 50% do vizinho simplesmente desistir de ser selecionado.

bool shouldGiveUp = UnityEngine.Random.Range(0, 1f) < 0.5f;

if (shouldGiveUp)
{
    continue;
}

Se chegarmos ao final do foreach iremos atribuir true à propriedade IsSelected desse vizinho. Também iremos adicioná-lo à fila de salas que devem ser visitadas pelo algoritmo. E incrementos a quantidade de salas selecionadas.

neighbor.IsSelected = true;
roomsToVisit.Enqueue(neighbor);
roomsCount++;

Com isso terminamos de escrever o método CreateMapLayout. Abaixo você pode ver o código completo.

private void CreateMapLayout()
{
    map = InitializeMap();

    Room startingRoom = map[width / 2, height / 2];
    startingRoom.IsSelected = true;

    Queue<Room> roomsToVisit = new Queue<Room>();
    roomsToVisit.Enqueue(startingRoom);

    int roomsCount = 1;

    while (roomsToVisit.Count > 0)
    {
        Room currentRoom = roomsToVisit.Dequeue();
        List<Room> currentRoomNeighbors = GetNeighbors(currentRoom);

        foreach (Room neighbor in currentRoomNeighbors)
        {
            if (neighbor.IsSelected)
            {
                continue;
            }

            if (HasMoreThanOneNeighbor(neighbor))
            {
                continue;
            }

            bool hasEnoughRoom = roomsCount >= numberOfRooms;

            if (hasEnoughRoom)
            {
                continue;
            }

            bool shouldGiveUp = UnityEngine.Random.Range(0, 1f) < 0.5f;

            if (shouldGiveUp)
            {
                continue;
            }

            neighbor.IsSelected = true;
            roomsToVisit.Enqueue(neighbor);
            roomsCount++;
        }
    }
}

Agora precisamos criar os métodos que deixamos para implementar mais tarde.

Chegou a hora de criar os métodos que deixamos para mais tarde

O primeiro método que iremos implementar será InitializeMap. A responsabilidade desse método é inicializar a matriz multidimensional que representa o mapa do jogo com a quantidade de salas corretas. Para isso vamos iterar pelo número de colunas e linhas que definimos nas variáveis width e height. E iremos atribuir a cada elemento da matriz uma nova sala.

private void InitializeMap()
{
    map = new Room[width, height];

    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            map[x, y] = new Room(new Vector3Int(x, y));
        }
    }
}

Em seguida, será a vez do método GetNeighbors. Esse método irá receber uma sala como parâmetro e irá selecionar os vizinhos dessa sala. No nosso caso só queremos os vizinhos que estão acima, abaixo, à esquerda e a direita. Para isso iremos adicionar 1 a posição x ou y, para pegar os vizinhos da direita e de cima, e subtrair 1 a posição x e y para pegar os vizinhos da esquerda e de baixo.

Só que antes de adicionarmos a sala à lista que será retornada pelo método, precisamos validar a posição da sala, caso contrário podemos receber a exceção IndexOutOfRangeException ao tentar acessar um elemento que não esteja na matriz que representa o mapa. Para isso iremos criar um novo método chamado IsRoomPositionValid que irá receber uma posição como argumento. Caso a posição seja válida pegamos uma referência a sala da matriz e adicionamos a uma lista que será retornada no final do método. Repetimos o mesmo processo para todas as direções.

private List<Room> GetNeighbors(Room room)
{
    List<Room> output = new List<Room>();

    int up = room.Position.y + 1;

    if (IsRoomPositionValid(new Vector3Int(room.Position.x, up)))
    {
        output.Add(map[room.Position.x, up]);
    }

    int down = room.Position.y - 1;

    if (IsRoomPositionValid(new Vector3Int(room.Position.x, down)))
    {
        output.Add(map[room.Position.x, down]);
    }

    int right = room.Position.x + 1;

    if (IsRoomPositionValid(new Vector3Int(right, room.Position.y)))
    {
        output.Add(map[right, room.Position.y]);
    }

    int left = room.Position.x - 1;

    if (IsRoomPositionValid(new Vector3Int(left, room.Position.y)))
    {
        output.Add(map[left, room.Position.y]);
    }

    return output;
}

O método IsRoomPositionValid é bem simples. Primeiro checamos se a posição x é menor que 0 ou maior ou igual a quantidade de colunas do mapa que definimos na variável width. Se sim para qualquer dos dois a posição não é válida e retornamos false.  Fazemos o mesmo para a posição y, só que queremos validar a quantidade de linha usando a variável height. Caso a posição não seja válida retornamos false. Se chegarmos ao final do método significa que a posição é válida, então podemos retornar true.

private bool IsRoomPositionValid(Vector3Int position)
{
    if (position.x < 0 || position.x >= width)
    {
        return false;
    }
        
    if (position.y < 0 || position.y >= height)
    {
        return false;
    }

    return true;
}

O último método que precisamos implementar é HasMoreThanOneNeighbor. Primeiro precisamos pegar uma lista dos vizinhos da sala que foi passada como argumento para esse método. Em seguida iteramos sobre essa lista e caso a propriedade IsSelected do vizinho seja true nós incrementamos a variável selectedNeighborsCount. E finalmente, comparamos a quantidade de vizinhos selecionados e caso seja maior que 1 retornamos verdadeiro.

private bool HasMoreThanOneNeighbor(Room room)
{
    List<Room> neighbors = GetNeighbors(room);
    int selectedNeighborsCount = 0;

    foreach (Room neighbor in neighbors)
    {
        if (neighbor.IsSelected)
        {
            selectedNeighborsCount++;
        }
    }

    return selectedNeighborsCount > 1;
}

A última coisa que precisamos fazer antes de voltar para a Unity é criar um método chamado Awake. Esse método é chamado automaticamente pela Unity quando esse script for criado no jogo, e chame o método CreateMapLayout, para que o layout do mapa seja criado quando o jogo começar.

private void Awake()
{
    CreateMapLayout();
}

Com isso, concluímos a geração do layout do mapa. Essa é uma boa hora para você voltar à Unity. Verifique se você adicionou o script como componente ao GameObject Map e entre no Play Mode para testar o que foi feito até o momento. Se estiver tudo correto, você não verá nada na tela, nem mesmo mensagens de erro.

Criar as salas no jogo

Agora que podemos gerar o layout do mapa precisamos criar as salas no mundo do jogo. 

Para isso, criaremos um novo GameObject e nomeamos como Room.

Em seguida clique com o botão direito sobre ele, e selecione 2D Object > Sprites > Square. Nomeio como Floor. Com isso, a Unity irá criar um GameObject com o componente Sprite Renderer e um sprite quadrado branco.

Com o GameObject Floor selecionado, na janela Inspector, modifique a propriedade Scale para x = 10 e y = 10 no componente Transform. Se quiser você pode alterar a propriedade Color do componente Sprite Renderer para a cor #DBE6E9. Esse GameObject será nosso chão.

Em seguida clique com o botão direito sobre o GameObject Room e selecione Create Empty. Nomeie o novo GameObject como Walls. Agora repita os passos usados para criar o GameObject Floor. 

Porém dessa vez, na propriedade Scale do componente Transform, modifique apenas x = 10. Mova o GameObject para que ele fique posicionado na borda superior do chão. Se quiser você pode alterar a propriedade Color do componente Sprite Renderer para a cor #8F8E8A. 

Clique com o botão direito sobre ele e selecione Duplicate, mova ele para que fique posicionado na borda inferior do chão. Duplique mais uma vez, porém modifique a propriedade Scale para x = 1 e y = 10. Mova para a borda esquerda. Duplique e mova para a borda direita. 

Exemplo da sala depois de pronta.

Agora vamos criar uma pasta chamada Prefab e arrastar o GameObject Room para criar um Prefab a partir dele. Agora podemos deletar esse GameObject da hierarquia, já que iremos criar cópias dele usando código.

Se você estiver usando os mesmo valores que eu coloquei no script Map, você pode selecionar o GameObject Main Camera e modificar a propriedade Position do componente Transform para x = 50 y = 50 z = -10. Além disso você pode modificar o componente Camera para que as propriedades Color seja igual a #0D0E0A e Size igual a 50. Isso fará com que o mapa apareça no meio da tela.

Depois disso, volte ao script Map no seu editor de texto e abaixo da variável numberOfRooms, declare a variável roomPrefab. Essa variável irá guardar o nosso modelo de sala que usaremos para criar as salas no mapa do jogo.

[SerializeField]
private GameObject roomPrefab;

Em seguida, crie um novo método chamado PlaceRooms. Esse método irá iterar sobre todas as sala na matriz map e caso a propriedade IsSelected seja true criaremos uma nova sala no mundo do jogo. 

Para calcular a posição da sala no mundo, usamos a posição dela na matriz e multiplicamos por 10. Usamos 10 porque esse é o valor que usamos na propriedade Scale quando criamos o GameObject da nossa sala. Em seguida usamos o método Instantiate para criar um novo GameObject na posição correta.

private void PlaceRooms()
{
    for (int x = 0; x < width; x++)
    {
        for (int y = 0; y < height; y++)
        {
            Room room = map[x, y];

            if (room.IsSelected)
            {
                Vector3 roomPosition = new Vector3(room.Position.x * 10, room.Position.y * 10);
                Instantiate(roomPrefab, roomPosition, Quaternion.identity, transform);
            }
        }
    }
}

Após criar esse método, chame ele logo abaixo do método CreateMapLayout no método Awake.

private void Awake()
{
    CreateMapLayout();
    PlaceRooms();
}

Volte para a Unity. Selecione o GameObject Map. Você verá que uma nova propriedade apareceu no componente Map. Arraste o Prefab da sala que criamos para esse campo. 

Entre no Play Mode e veja o mapa que foi gerado. Faça isso algumas vezes para ver mapas diferentes.

Novo mapa ao pressionar uma tecla

Agora já podemos criar mapas aleatórios, mas ainda tem algumas coisas que podemos fazer para melhorar o programa. Uma dessas coisas, que você provavelmente vai querer remover ou ao menos esconder do jogador, mas que será útil para testarmos a geração é criar um novo mapa quando apertamos uma tecla no teclado.

Para fazer isso acontecer, primeiro vamos adicionar um novo método chamado RemoveRooms. Ele irá iterar sobre todos os GameObjects que são filhos do atual e usar o método Destroy para removê-los do jogo.

private void RemoveRooms()
{
    List<Transform> rooms = transform.Cast<Transform>().ToList();

    foreach (var child in rooms)
    {
        Destroy(child.gameObject);
    }
}

Em seguida vamos criar um novo método chamado CreateNewMap e tornaremos ele público para que mais tarde possamos gerar mapas através de outros scripts. Neste método iremos chamar os métodos RemoveRooms, CreateMapLayout, PlaceRooms. A ordem com que esse métodos são chamados é importante, primeiro removemos todas as salas que já estão no jogo, então criamos o novo layout, para enfim criarmos novas salas no jogo.

public void CreateNewMap()
{
    RemoveRooms();
    CreateMapLayout();
    PlaceRooms();
}

Agora, no método Awake ao invés de usar os métodos CreateMapLayout e PlaceRooms, use o métodos CreateNewMap.

private void Awake()
{
    CreateNewMap();
}

Em seguida crie um novo método chamado Update, esse método é chamado todo quadro pela Unity. Usaremos ele para capturar quando uma tecla for pressionada e gerar um novo mapa.

Para isso, usaremos o método GetKeyDown da classe Input. Esse método retorna verdadeiro no quadro em que uma tecla é pressionada. No nosso caso, queremos saber quando a tecla de espaço for pressionada e quando isso acontecer queremos chamar o método CreateNewMap.

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        CreateNewMap();
    }
}

Volte para a Unity, entre no Play Mode e pressione a tecla de espaço no teclado. Agora podemos gerar quantos mapas quisermos sem ter que sair e entrar do Play Mode. Não sei você, mas eu acho divertido ficar olhando os mapas que são gerados e que tipo de conteúdo eles poderiam ter. Além disso, vai facilitar testar se os mapas estão sendo gerados de maneira correta.

Garantir que o mapa sempre tenha a quantidade certa de salas

Agora que podemos visualizar os mapas gerados de forma fácil, podemos perceber que algumas vezes o mapa é gerado com menos salas do que queremos, algumas vezes apenas com a sala inicial. Isso é um problema.

Podemos resolver esse problema com uma pequena mudança no método CreateMapLayout. Se você se lembra toda vez que selecionamos uma sala do mapa, nós incrementamos a variável roomsCount. Ela conta quantas salas nós adicionamos ao mapa, e usamos ela para parar de buscar mais salas quando chegamos a quantidade correta. Também podemos usar ela para caso não tenhamos a quantidade correta executar a geração novamente.

Para isso, primeiro vamos mudar a declaração da variável roomsCount para o início do método. Na linha seguinte criamos um novo bloco while que irá checar se o mapa tem a quantidade correta de salas, se sim, nós terminamos de gerar o mapa, caso contrário, precisamos gerar um novo. 

Agora podemos mover todo o resto do método para dentro desse bloco. Não podemos esquecer de atribuirmos o valor 1 a variável roomsCount quando adicionamos a sala inicial, para que a contagem esteja correta.

private void CreateMapLayout()
    {
        int roomsCount = 0;

        while (roomsCount < numberOfRooms)
        {
            map = InitializeMap();

            Room startingRoom = map[width / 2, height / 2];
            startingRoom.IsSelected = true;

            Queue<Room> roomsToVisit = new Queue<Room>();
            roomsToVisit.Enqueue(startingRoom);

            roomsCount = 1;

            while (roomsToVisit.Count > 0)
            {
                Room currentRoom = roomsToVisit.Dequeue();
                List<Room> currentRoomNeighbors = GetNeighbors(currentRoom);

                foreach (Room neighbor in currentRoomNeighbors)
                {
                    if (neighbor.IsSelected)
                    {
                        continue;
                    }

                    if (HasMoreThanOneNeighbor(neighbor))
                    {
                        continue;
                    }

                    bool hasEnoughRoom = roomsCount >= numberOfRooms;

                    if (hasEnoughRoom)
                    {
                        continue;
                    }

                    bool shouldGiveUp = UnityEngine.Random.Range(0, 1f) < 0.5f;

                    if (shouldGiveUp)
                    {
                        continue;
                    }

                    neighbor.IsSelected = true;
                    roomsToVisit.Enqueue(neighbor);
                    roomsCount++;
                }
            }
        }
    }

Agora volte para Unity e teste o gerador novamente. Você verá que agora sempre temos o número correto de salas.

Salas especiais para chefões e tesouros

Agora que podemos gerar o mapa e garantir que ele sempre tenha a quantidade correta de salas, vamos adicionar duas salas especiais, uma para o chefão do mapa e outra para tesouros que o jogador pode achar. 

Primeiro vamos criar alguns prefabs para as novas salas. Na Unity selecione o Prefab que já temos para a sala e pressione ctrl + d no teclado, isso irá duplicar o prefab. Faça isso mais uma vez. Agora renomeie um dos Prefab para Room_Boss e outro para Room_Treasure.

Em seguida crie uma pasta nova com o nome Sprites. Depois, importe os dois ícones que disponibilizei no início do tutorial arrastando a imagem da pasta do seu sistema operacional até a pasta Sprites na Unity.

Selecione as duas imagens e na janela Inspector modifique as propriedades para que fique igual a imagem abaixo.

Com os sprites importados, dê dois cliques no Prefab Room_Boss para editar o prefab. Depois de aberto, arraste a imagem de caveira para a janela Hierarchy. Selecione o GameObject e na janela Inspector, modifique o valor da propriedade Order in Layer para 2 no componente Sprite Renderer, caso não consiga ver a propriedade clique em Additional Settings. Essa mudança faz com que esse sprites seja sempre renderizado na frente dos outros sprites na mesma camada, algo parecido com o que podemos fazer com as camadas em alguns programas de edição de imagem, como o Photoshop, por exemplo.

Faça o mesmo para o Prefab Room_Treasure.

Exemplo de salas especiais. Na esquerda a sala do chefão, na direita a de tesouro.

Com os Prefabs das novas salas criados, vamos voltar ao script Map.

Abaixo da variável roomPrefab, iremos declarar mais duas variáveis, uma chamada roomBossPrefab, e a outra roomTreasurePrefab. Não podemos esquecer de marcar elas com o atributo SerializeField para que possamos editar seu valor diretamente no editor. Usaremos elas para guardar os Prefabs da sala do chefão e do tesouro, respectivamente.

[SerializeField]
private GameObject roomPrefab;
[SerializeField]
private GameObject roomBossPrefab;
[SerializeField]
private GameObject roomTreasurePrefab;

E na linha seguinte onde declaramos a variável map. Vamos declarar uma lista chamada deadEndRooms e inicializamos ela. Ela será usada para guardar todas as salas que não tem saída.

private Room[,] map;
private List<Room> deadEndRooms = new List<Room>();

Agora voltamos ao método CreateMapLayout, precisamos fazer algumas pequenas mudanças nele. 

Primeiro, na linha seguinte a que inicializamos o mapa, precisamos usar o método Clear na lista que criamos. Isso é importante para garantir que qualquer informação que a lista tenha da geração anterior seja apagada.

InitializeMap();
deadEndRooms.Clear();

No segundo bloco while, depois da variável currentRoomNeighbors, vamos declarar uma nova variável chamada hasAddedNewRoom, usaremos ela para saber se adicionamos uma nova sala. Precisamos inicializar a variável com false.

Room currentRoom = roomsToVisit.Dequeue();
List<Room> currentRoomNeighbors = GetNeighbors(currentRoom);
bool hasAddedNewRoom = false;

Se chegamos no final do bloco foreach, isso significa que adicionamos uma nova sala, então iremos atribuir true a hasAddedNewRoom, depois de incrementar a variável roomsCount.

neighbor.IsSelected = true;
roomsToVisit.Enqueue(neighbor);
roomsCount++;
hasAddedNewRoom = true;

Após o foreach iremos checar a valor da variável hasAddedNewRoom. E caso o valor da variável ainda seja false, iremos adicionar a sala atual a lista deadEndRooms.

if (!hasAddedNewRoom)
{
    deadEndRooms.Add(currentRoom);
}

Agora que sabemos quais salas não tem saída, precisamos criar mais alguns métodos.

O primeiro será chamado de InstantiateRoom, este método terá dois parâmetros, o primeiro um chamado roomPrefab que representa o Prefab da sala, o segundo chamado room, que contém a informação da sala durante a geração do mapa. 

O  método InstantiateRoom será responsável por criar a sala no jogo. Para isso copie as linhas que estão dentro do bloco if no método PlaceRooms. Não precisamos fazer nenhuma mudança. Porém iremos retornar o GameObject que o método Instantiate criar, no momento não precisamos dele, mas se no futuro precisarmos, não precisaremos modificar esse método.

private GameObject InstantiateRoom(GameObject roomPrefab, Room room)
{
    Vector3 roomPosition = new Vector3(room.Position.x * 10, room.Position.y * 10);
    return Instantiate(roomPrefab, roomPosition, Quaternion.identity, transform);
}

Em seguida crie um novo método chamado PlaceBossRoom. Ele será responsável por criar a sala do chefão. Primeiro precisamos selecionar a última sala adicionada a lista deadEndRooms. Para isso usaremos Linq, que é uma biblioteca que contém métodos úteis para trabalhar com listas. Porém, para podermos usar os métodos precisamos declarar using System.Linq no início do script. Com isso podemos usar o método Last para selecionar o último elemento de uma lista. No nosso caso a última sala. Então chamamos InstantiateRoom passando o Prefab da sala do chefão e a sala que selecionamos como argumentos.

private void PlaceBossRoom()
{
    Room room = deadEndRooms.Last();
    InstantiateRoom(roomBossPrefab, room);
}

O próximo método que iremos adicionar será chamado de PlaceTreasureRooms. Ele será responsável por criar as salas de tesouro no mapa. Primeiro precisamos de uma lista que não contenha a última sala. Para isso usaremos Linq novamente. Com método Where, o método aceita uma expressões lambda como argumento, parece complicado, mas de forma simplificada é uma forma de criarmos um método. Nessa expressões lambda nós comparamos todas as sala na lista e caso ela não seja a última nós adicionamos a sala ao resultado. No final usamos o método ToList para converter o resultado em uma lista.

Então iteramos sobre essa lista sobre essa lista, e usamos a classe Random para decidir a chance da sala ter um tesouro, e caso a sala tenha um tesouro usamos o método InstantiateRoom para criar uma sala de tesouro, caso contrário criamos uma sala normal.

private void PlaceTreasureRooms()
{
    List<Room> rooms = deadEndRooms.Where(room => room != deadEndRooms.Last()).ToList();

    foreach (Room room in rooms)
    {
        float treasureChance = UnityEngine.Random.Range(0f, 1f);
        bool hasTreasure = treasureChance > 0.7f;

        if (hasTreasure)
        {
            InstantiateRoom(roomTreasurePrefab, room);
        }
        else
        {
            InstantiateRoom(roomPrefab, room);
        }
    }
}

O último método que iremos criar se chama PlaceNormalRooms e é responsável por criar as salas normais do mapa. Para isso nós iteramos sobre todas as salas do mapa e caso a propriedade IsSelected seja true e ela não esteja na lista deadEndRooms, usamos o método InstantiateRoom para criar uma sala normal.

private void PlaceNormalRooms()
{
    foreach (Room room in map)
    {
        if (room.IsSelected && !deadEndRooms.Contains(room))
        {
            InstantiateRoom(roomPrefab, room);
        }
    }
}

Por fim, podemos modificar o método PlaceRooms. Apague todo o conteúdo e chame os métodos PlaceNormalRooms, PlaceBossRoom, PlaceTreasureRooms para criar todas as salas do mapa.

private void PlaceRooms()
{
    PlaceNormalRooms();
    PlaceBossRoom();
    PlaceTreasureRooms();
}

Volte para a Unity. Selecione o GameObject Map e adicione os Prefabs ao componente Map. Agora entre no Play Mode e veja o resultado.

Portas entre as salas

Agora só falta adicionarmos portas ao nosso mapa.

Primeiro, vamos criar os Prefabs de porta para cada direção. Para isso, duplique uma das salas quatro vezes. E nomeie cada Prefab como Room_Door_Down, Room_Door_Up, Room_Door_Left, Room_Door_Right.

Dê dois cliques em um Prefab para começar a editá-lo. Em seguida delete todas as bordas que não corresponda a direção daquela porta. Em seguida, selecione a parede correta e no Inspector, mude a propriedade Scale no componente Transform para 4 no eixo x caso a seja a porta para baixo ou para cima, ou no eixo y, caso seja para esquerda ou para direita. Arraste o GameObject para se posicionar de um dos lados, duplique ele e posicione do lado oposto. Repita esse processo para todas as portas.

Exemplo de como as portas devem ficar. Na parte de cima, ainda temos o chão para auxiliar no alinhamento. Em baixo, a versão final que será criada no jogo.

Em seguida, precisamos fazer algumas modificações nos prefabs das salas, dê dois clique em uma das salas para começar a editar. Primeiro, selecione o GameObject que contém todas as paredes e se ainda não tiver nomeado, nomeie esse GameObject como Walls. Agora selecione cada uma das paredes para indicar qual sua posição na sala. Para a parede de cima, nomeie como Up, a de baixo como Down, a da esquerda como Left, a da direita como Right. É importante que os nomes estejam corretos, pois usamos eles para encontrar e remover a parede antes de adicionarmos a porta.

O importante aqui não é a ordem e sim o nome de cada GameObject. Mas no final, a hierarquia deve ser similar.

Voltamos para o script Map. Após a variável roomTreasurePrefab, iremos declarar quatro variáveis, uma para cada direção que terá uma porta. Não esqueça de marcar todas com o atributo SerializeField para que possamos editá-las diretamente no editor.

[SerializeField]
private GameObject doorUpPrefab;
[SerializeField]
private GameObject doorDownPrefab;
[SerializeField]
private GameObject doorRightPrefab;
[SerializeField]
private GameObject doorLeftPrefab;

Agora, iremos criar alguns métodos para remover as paredes e adicionar as portas.

O primeiro será chamado IsRoomValid. Ele será responsável por validar se uma sala existe. Primeiro usamos o método IsRoomPositionValid para saber se a posição da sala é válida. Caso a posição seja inválida, retornamos false. Em seguida, selecionamos a sala na matriz map e retornamos o valor da propriedade IsSelected. Uma sala será válida apenas quando o valor da propriedade seja true.

private bool IsRoomValid(Vector3Int position)
{
    if (!IsRoomPositionValid(position))
    {
        return false;
    }

    Room room = map[position.x, position.y];

    return room.IsSelected;
}

O segundo será chamado de ReplaceWallWithDoor. Ele será responsável por destruir a parede e construir a porta correta no lugar. Para isso precisaremos saber qual o GameObject da sala no mundo do jogo, o nome da parede que queremos destruir, esse será o mesmo nome que usamos para nomear as paredes nos Prefabs, caso estejam errados a Unity não conseguirá encontrar a parede correta.

Primeiro selecionamos o GameObject que contém as paredes na sala, novamente, o nome é importante. Então encontramos a parede que queremos remover. Em seguida, criamos a porta no lugar usando o método Instantiate, e destruímos a parede que está no lugar.

private void ReplaceWallWithDoor(GameObject roomGameObject, string wallName, GameObject doorPrefab)
{
    Transform wall = roomGameObject.transform.Find("Walls");
    Transform wallPiece = wall.Find(wallName);
    Instantiate(doorPrefab, wall, false);
    Destroy(wallPiece.gameObject);
}

O último método que criaremos será AddDoorsToRoom. Ele será responsável por verificar quais paredes devem ser removidas. Para isso precisaremos saber qual o GameObject da sala no mundo do jogo, e qual a informação da sala durante a geração. Esse método é similar ao método GetNeighbors na forma com a qual usamos para selecionar os vizinhos, isto é, para saber qual o vizinho em cada direção, iremos adicionar 1 a posição x ou y, para pegar os vizinhos da direita e de cima, e subtrair 1 a posição x e y para pegar os vizinhos da esquerda e de baixo. Então usamos o método IsRoomValid para saber se a sala vizinha existe e está selecionada. Se sim, usamos o método ReplaceWallWithDoor para criar a porta no lugar. Fazemos isso para todas as direções.

private void AddDoorsToRoom(GameObject roomGameObject, Room room)
{
    int up = room.Position.y + 1;

    if (IsRoomValid(new Vector3Int(room.Position.x, up)))
    {
        ReplaceWallWithDoor(roomGameObject, "Up", doorUpPrefab);
    }

    int down = room.Position.y - 1;

    if (IsRoomValid(new Vector3Int(room.Position.x, down)))
    {
        ReplaceWallWithDoor(roomGameObject, "Down", doorDownPrefab);
    }

    int right = room.Position.x + 1;

    if (IsRoomValid(new Vector3Int(right, room.Position.y)))
    {
        ReplaceWallWithDoor(roomGameObject, "Right", doorRightPrefab);
    }

    int left = room.Position.x - 1;

    if (IsRoomValid(new Vector3Int(left, room.Position.y)))
    {
        ReplaceWallWithDoor(roomGameObject, "Left", doorLeftPrefab);
    }
}

Por fim, no método InstantiateRoom, iremos salvar o GameObject criado com o método Instantiate e vamos passar esse GameObject para o método AddDoorsToRoom como argumento junto das informações da sala. Por fim retornamos o GameObject.

private GameObject InstantiateRoom(GameObject roomPrefab, Room room)
{
    Vector3 roomPosition = new Vector3(room.Position.x * 10, room.Position.y * 10);
    GameObject roomGameObject = Instantiate(roomPrefab, roomPosition, Quaternion.identity, transform);
    AddDoorsToRoom(roomGameObject, room);
        
    return roomGameObject;
}

Volte para a Unity. Selecione o GameObject Map e adicione os Prefabs das portas ao componente Map. Agora entre no Play Mode e veja o resultado.

Salas com tamanhos diferentes

Para finalizar esse tutorial vamos ver como podemos usar salas de tamanhos diferentes. Porém uma limitação desse gerador é que o tamanho ainda precisa se encaixar perfeitamente no grid. Por exemplo, no momento todas as salas medem 10×10 unidades, podemos modificar o tamanho para que elas tenham 20×10 unidades. Porém não podemos combinar as duas. 

Entretanto, o design de cada sala é livre dentro desse espaço. Podemos pensar em cada sala como uma folha de sulfite em branco. Mas é importante que as portas estejam sempre no mesmo lugar.

Para isso vamos criar duas variáveis no início do script Map, a primeira chamada roomWidth e a segunda roomHeight e marcamos elas com SerializeField para podermos editá-las no editor. Elas representam a largura e a altura de uma sala, respectivamente.

[SerializeField]
private float roomWidth = 10;
[SerializeField]
private float roomHeight = 10;

Agora, no método InstantiateRoom, iremos substituir o valor 10 que usamos para posicionar a sala no mundo por essas variáveis que acabamos de criar.

private GameObject InstantiateRoom(GameObject roomPrefab, Room room)
{
    Vector3 roomPosition = new Vector3(room.Position.x * roomWidth, room.Position.y * roomHeight);
    GameObject roomGameObject = Instantiate(roomPrefab, roomPosition, Quaternion.identity, transform);
    AddDoorsToRoom(roomGameObject, room);
        
    return roomGameObject;
}

Agora volte ao editor, e crie as salas e as portas do tamanho que deseja, insira elas no gerador e veja o que acontece.

Mapas com salas de tamanhos diferentes.

Conclusão

Com isso concluímos o nosso gerador de mapas inspirado em Binding of Isaac. No momento ele está ainda bem cru, mas tem potencial para fazermos dele o que quisermos.

A maior limitação desse método de geração é que ficamos presos ao grid que criamos. Dessa forma todas as salas devem ter o mesmo tamanho. Não que isso nos impeça de ser criativos quando estivermos criando o design das salas, já que desde que a sala esteja no tamanho correto e as portas nos locais corretos podemos fazer o que quisermos dentro dessas limitações.

Falando em salas, nada nos obriga que cada sessão seja dividida por uma parede e porta. Se cada peça que compõe o mapa fizer sentido, quando estiverem juntas podemos fazer o quisermos. Estamos limitados apenas pela nossa imaginação. Além disso, podemos adicionar checagens para combinar diferentes salas dependendo do seu vizinho.

Uma das coisa que gostaria de modificar é como selecionamos as salas, no momento temos apenas um Prefab para cada tipo de sala, o ideal seria ter uma lista de prefabs e escolher um de forma aleatória.

Esse é um projeto que gostaria de revisitar no futuro e tenho algumas ideias de como podemos expandi-lo. Se você tiver alguma sugestão do que podemos fazer ou alguma coisa que gostaria de ver deixe seu comentário aqui ou nas redes sociais do blog.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *