Padrões procedurais para usar com Tilemaps, parte 2

ETHAN BRUINS / UNITY TECHNOLOGIES Contributor
Jun 7, 2018|14 Min
Padrões procedurais para usar com Tilemaps, parte 2
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Na parte 1, vimos algumas maneiras de criar camadas superiores proceduralmente usando vários métodos, como Ruído Perlin e Caminhada Aleatória. Neste post, veremos algumas maneiras de criar cavernas com geração procedural, o que deve lhe dar uma ideia das possíveis variações disponíveis.

Tudo o que vamos falar neste post do blog está disponível neste projeto. Sinta-se à vontade para baixar os ativos e testar os algoritmos procedurais.

Imagem
Imagem

Esta postagem do blog está de acordo com as mesmas regras da Parte I. Para lembrá-lo, essas regras são:

  • A maneira de distinguir entre ser um bloco ou não é usando binário. 1 sendo ligado e 0 sendo desligado.
  • Armazenaremos todos os nossos mapas em uma matriz de inteiros 2D, que será retornada ao usuário no final de cada função (exceto quando renderizamos).
  • Usarei a função de matriz GetUpperBound() para obter a altura e a largura do mapa. Isso significa que temos menos variáveis em cada função, permitindo um código mais limpo.
  • Costumo usar Mathf.FloorToInt()porque o sistema de coordenadas do tilemap começa no canto inferior esquerdo e usar Mathf.FloorToInt() nos permite arredondar os números para um inteiro.
  • Todo o código fornecido nesta postagem do blog está em C#.
Ruído de Perlin

Na postagem anterior do blog, vimos algumas maneiras de usar ruído Perlin para criar camadas superiores. Felizmente, também podemos usar o Ruído Perlin para criar uma caverna. Fazemos isso obtendo um novo valor de ruído Perlin, que considera os parâmetros da nossa posição atual multiplicados por um modificador. O modificador é um valor entre 0 e 1. Quanto maior o valor do modificador, mais confusa será a geração de Perlin. Em seguida, arredondamos esse valor para um número inteiro de 0 ou 1, que armazenamos na matriz do mapa. Dê uma olhada na implementação:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

A razão pela qual usamos um modificador em vez de uma semente é porque os resultados da geração Perlin parecem melhores quando multiplicamos os valores por um número entre 0 e 0,5. Quanto menor o valor, mais irregular será o resultado. Dê uma olhada em alguns dos resultados. Este gif começa com um valor modificador de 0,01 e vai até 0,25 em incrementos.

Imagem
Imagem

Neste gif, você pode ver que a geração Perlin está, na verdade, apenas ampliando o padrão a cada tique.

Caminhada aleatória

No post anterior, vimos que podemos usar o cara ou coroa para determinar se a plataforma vai subir ou descer. Neste post, usaremos a mesma ideia, mas com duas opções adicionais para esquerda e direita. Esta variação do algoritmo Random Walk nos permite criar cavernas. Fazemos isso obtendo uma direção aleatória, então movemos nossa posição e removemos a peça. Continuamos esse processo até atingirmos a quantidade necessária de piso que precisamos destruir. No momento, estamos usando apenas 4 direções: cima, baixo, esquerda, direita.

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

Começamos a função com:

Encontrando nossa posição inicial

Calculando o número de ladrilhos que precisamos remover

Removendo o ladrilho na posição inicial

Adicionando um à nossa contagem de andares

Em seguida, passamos para o loop while. Isso criará a caverna para nós:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

Então, o que estamos fazendo aqui?

Bem, primeiro, estamos decidindo em qual direção devemos nos mover usando um número aleatório. Em seguida, verificamos a nova direção com uma instrução switch case. Nesta declaração, verificamos se a posição é uma parede. Caso contrário, removemos o pedaço de ladrilho do conjunto. Continuamos fazendo isso até atingirmos o valor mínimo necessário. O resultado final é mostrado abaixo:

Imagem
Imagem

Também criei uma versão personalizada desta função, que inclui direções diagonais. O código para esta função é um pouco longo, então se você quiser dar uma olhada, confira o link para o projeto no início desta postagem do blog.

Túnel direcional

Um túnel direcional começa em uma extremidade do mapa e depois faz um túnel até a extremidade oposta. Podemos controlar a curvatura e a rugosidade do túnel inserindo-as na função. Também podemos determinar o comprimento mínimo e máximo das partes do túnel. Vamos dar uma olhada na implementação abaixo:

public static int[,] DirectionalTunnel(int[,] mapa, int minPathWidth, int maxPathWidth, int maxPathChange, int rugosidade, int curvatura)
{
//Este valor vai de sua contraparte negativa para seu valor positivo, neste caso com um valor de largura de 1, a largura do túnel é 3
int larguradotúnel = 1;
//Defina a posição inicial X para o centro do túnel
int x = map.GetUpperBound(0) / 2;

//Configurar nosso aleatório com a semente
System.Random rand = new System.Random(Time.time.GetHashCode());

//Criar a primeira parte do túnel
para (int i = -largura do túnel; i <= largura do túnel; i++)
{
map[x + i, 0] = 0;
}

Então o que está acontecendo?

Primeiro, definimos um valor de largura. Este valor de largura irá do seu valor negativo para o seu valor positivo. Isso acabará nos dando o tamanho real que queremos. Neste caso, estamos usando o valor 1. Isso, por sua vez, nos dará uma largura total de 3, porque usaremos os valores -1, 0, 1.

A próxima coisa que fazemos é definir a posição x inicial, o que é feito obtendo o meio da largura do mapa. Agora que configuramos esses primeiros valores, podemos fazer o tunelamento da primeira parte do mapa.

Imagem
Imagem

Agora vamos criar o restante do mapa.

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

Gere um número aleatório para verificar nosso valor de rugosidade. Se estiver acima do valor, podemos alterar a largura do caminho. Também verificamos se estamos deixando a largura muito pequena. Com o próximo trecho de código, estamos trabalhando no mapa, criando túneis à medida que avançamos. Com cada etapa, fazemos o seguinte:

Gere um novo número aleatório para verificar em relação ao nosso valor de curva. Assim como na verificação anterior, se estiver acima do valor, podemos alterar o ponto central do caminho. Também verificamos se não estamos saindo das bordas do mapa.

Por fim, fazemos o tunelamento da nova seção que criamos.

Os resultados finais desta implementação são assim:

imagem
imagem
Autômatos celulares

Autômatos celulares usam uma vizinhança de células para determinar se a célula atual está ligada (1) ou desligada (0). A base para essas vizinhanças usa uma grade de células gerada aleatoriamente. No nosso caso, vamos gerar essa grade inicial usando a função Random.Next em C#.

Como temos algumas implementações diferentes de Autômatos Celulares, criei uma função separada para gerar essa grade base. A função se parece com isso:

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

Nesta função, também podemos determinar se queremos paredes em nossa grade. Fora isso, é relativamente simples. Verificamos um número aleatório em relação à nossa porcentagem de preenchimento para determinar se a célula atual está ativada ou desativada. Dê uma olhada no resultado:

Imagem
Imagem
Bairro Moore

O Bairro Moore é usado para ajudar a suavizar a geração inicial de Autômatos Celulares. O bairro de Moore se parece com isso:

Imagem
Imagem

As regras para o bairro são as seguintes:

  • Verifique se há um vizinho em todas as direções.
  • Se um vizinho for um bloco ativo, adicione um aos blocos ao redor.
  • Se um vizinho não for uma peça ativa, não faça nada.
  • Se a célula tiver mais de 4 peças ao redor, torne-a uma peça ativa.
  • Se a célula tiver exatamente 4 peças ao redor, deixe-as em paz.
  • Repita até que tenhamos tentado todas as peças do mapa.

A função para verificar o Bairro Moore é a seguinte:

estático int GetMooreSurroundingTiles(int[,] mapa, int x, int y, bool edgesAreWalls)
{
/* O bairro de Moore se parece com isso ('T' é o nosso ladrilho, 'N' são os nossos vizinhos)
*
* N N N
* N T N
* N N N
*
*/

int tileCount = 0;

para(int vizinhoX = x - 1; vizinhoX <= x + 1; vizinhoX++)
{
para(int vizinhoY = y - 1; vizinhoY <= y + 1; vizinhoY++)
{
se (vizinhoX >= 0 && vizinhoX < map.GetUpperBound(0) && vizinhoY >= 0 && vizinhoY < map.GetUpperBound(1))
{
//Não queremos contar o ladrilho que estamos verificando os arredores
se(vizinhoX != x || vizinhoY != y)
{
tileCount += map[neighbourX, neighbourY];
}
}
}
}
retornar tileCount;
}

Depois de verificarmos nosso ladrilho, passamos a usar as informações em nossa função de suavização. Novamente, assim como na geração inicial dos Autômatos Celulares, podemos definir se as bordas do mapa são paredes.

público estático int[,] SmoothMooreCellularAutomata(int[,] mapa, bool edgesAreWalls, int smoothCount)
{
para (int i = 0; i < smoothCount; i++)
{
para (int x = 0; x < map.GetUpperBound(0); x++)
{
para (int y = 0; y < map.GetUpperBound(1); y++)
{
int surroundingTiles = GetMooreSurroundingTiles(mapa, x, y, bordasSãoParedes);

se (arestasSãoParedes && (x == 0 || x == (mapa.GetUpperBound(0) - 1) || y == 0 || y == (mapa.GetUpperBound(1) - 1)))
{
//Defina a borda como uma parede se tivermos edgesAreWalls como verdadeiro
map[x, y] = 1;
}
//A regra de Moore padrão requer mais de 4 vizinhos
senão se (surroundingTiles > 4)
{
map[x, y] = 1;
}
senão se (surroundingTiles < 4)
{
map[x, y] = 0;
}
}
}
}
//Retorna o mapa modificado
mapa de retorno;
}

Um ponto importante a ser observado nessa função é o fato de que temos um loop for para suavizar o mapa um certo número de vezes. Isso acaba nos dando um mapa mais bonito como resultado.

Imagem
Imagem

Poderíamos sempre modificar esse algoritmo conectando salas umas às outras se, por exemplo, houver apenas 2 blocos entre elas.

Bairro von Neumann

A Vizinhança de von Neumann é outro método de implementação popular para Autômatos Celulares. Para esta geração, usamos uma vizinhança mais simples do que aquela que usamos na Geração Moore. O bairro se parece com isso:

Título
Título

As regras para este bairro são as seguintes:

  • Verifique ao redor do ladrilho até os vizinhos diretos, sem incluir as diagonais.
  • Se a célula estiver ativa, adicione um à nossa contagem.
  • Se a célula estiver inativa, não faça nada.
  • Se tivermos mais de 2 vizinhos, torne a célula atual ativa.
  • Se tivermos menos de 2 vizinhos, torne a célula atual inativa.
  • Se tivermos exatamente 2 vizinhos, não modifique a célula atual.

O segundo resultado segue os mesmos princípios do primeiro, mas expande a área do bairro.

Verificamos os vizinhos usando a seguinte função:

estático int GetVNSurroundingTiles(int[,] mapa, int x, int y, bool edgesAreWalls)
{
/* A vizinhança de von Neumann se parece com isso ('T' é nosso Tile, 'N' é nosso vizinho)
*
* N
* N T N
* N
*
*/

int tileCount = 0;

//Mantenha as bordas como paredes
se(arestasSãoParedes && (x - 1 == 0 || x + 1 == mapa.GetUpperBound(0) || y - 1 == 0 || y + 1 == mapa.GetUpperBound(1)))
{
tileCount++;
}

//Certifique-se de que não estamos tocando no lado esquerdo do mapa
if(x - 1 > 0)
{
tileCount += map[x - 1, y];
}

//Certifique-se de que não estamos tocando a parte inferior do mapa
if(y - 1 > 0)
{
tileCount += map[x, y - 1];
}

//Certifique-se de que não estamos tocando no lado direito do mapa
if(x + 1 < map.GetUpperBound(0))
{
tileCount += map[x + 1, y];
}

//Certifique-se de que não estamos tocando o topo do mapa
se(y + 1 < mapa.GetUpperBound(1))
{
tileCount += map[x, y + 1];
}

retornar tileCount;
}

Depois de termos o resultado de quantos vizinhos temos, podemos prosseguir para a suavização da matriz. Como antes, temos um loop for para iterar pela suavização para a quantidade necessária inserida.

público estático int[,] SmoothVNCellularAutomata(int[,] mapa, bool edgesAreWalls, int smoothCount)
{
para (int i = 0; i < smoothCount; i++)
{
para (int x = 0; x < map.GetUpperBound(0); x++)
{
para (int y = 0; y < map.GetUpperBound(1); y++)
{
//Obtenha os tiles ao redor
int surroundingTiles = GetVNSurroundingTiles(mapa, x, y, bordasSãoParedes);

se (arestasSãoParedes && (x == 0 || x == mapa.GetUpperBound(0) - 1 || y == 0 || y == mapa.GetUpperBound(1)))
{
//Mantenha nossas bordas como paredes
map[x, y] = 1;
}
//von Neuemann Neighbourhood requer apenas 3 ou mais peças ao redor para serem alteradas para uma peça
senão se (surroundingTiles > 2)
{
map[x, y] = 1;
}
senão se (surroundingTiles < 2)
{
map[x, y] = 0;
}
}
}
}
//Retorna o mapa modificado
mapa de retorno;
}

O resultado final parece muito mais quadrado do que o Moore Neighbourhood, como pode ser visto abaixo:

imagem
imagem

Novamente, assim como no Moore Neighbourhood, poderíamos prosseguir com a execução de um script adicional sobre a geração para fornecer melhores conexões entre áreas do mapa.

Conclusão

Espero ter inspirado você a começar a usar alguma forma de geração procedural em seus projetos. Se você ainda não baixou o projeto, você pode obtê-lo aqui. Se você quiser aprender mais sobre mapas de geração procedural, confira o Procedural Generation Wiki ou o Roguebasin.com, pois ambos são ótimos recursos.

Se você fizer algo legal usando geração procedural, fique à vontade para me deixar uma mensagem no Twitter ou deixar um comentário abaixo!

Geração de procedimentos 2D na Unite Berlin

Quer saber mais sobre isso e assistir a uma demonstração ao vivo? Também falarei sobre Padrões Procedurais para usar com Tilemaps no Unite Berlin, no mini-teatro do salão de exposições em 20 de junho. Estarei por perto depois da palestra se você quiser bater um papo pessoalmente!