A geração de procedimentos é usada para aumentar a variabilidade dos jogos. Projetos conhecidos incluem
Minecraft ,
Enter the Gungeon e
Descenders . Neste post, explicarei alguns dos algoritmos que podem ser usados ao trabalhar com o sistema
Tilemap , que apareceu como uma função 2D no Unity 2017.2 e com o
RuleTile .
Com a criação processual de mapas, cada jogo que passa será único. Você pode usar vários dados de entrada, como hora ou nível atual do jogador, para alterar dinamicamente o conteúdo, mesmo após a montagem do jogo.
Sobre o que é este post?
Veremos algumas das maneiras mais comuns de criar mundos procedimentais, bem como algumas variações que eu criei. Aqui está um exemplo do que você pode criar depois de ler o artigo. Três algoritmos trabalham juntos para criar um mapa usando o
Tilemap e o
RuleTile :
No processo de gerar um mapa usando qualquer algoritmo, obtemos uma matriz
int
contendo todos os novos dados. Você pode continuar a modificar esses dados ou renderizá-los em um mapa de blocos.
Antes de ler mais, seria bom saber o seguinte:
- Distinguimos o que é um bloco e o que não está usando valores binários. 1 é uma peça, 0 é a sua ausência.
- Armazenaremos todos os cartões em um array inteiro bidimensional retornado ao usuário no final de cada função (exceto aquele em que a renderização é realizada).
- Usarei a função de matriz GetUpperBound () para obter a altura e a largura de cada mapa, para que a função receba menos variáveis e o código seja mais limpo.
- Costumo usar Mathf.FloorToInt () , porque o sistema de coordenadas Tilemap é iniciado no canto inferior esquerdo, e Mathf.FloorToInt () permite arredondar números para um número inteiro.
- Todo o código nesta postagem está escrito em C #.
Geração de matriz
GenerateArray cria uma nova matriz
int
do tamanho especificado. Também podemos indicar se a matriz deve ser preenchida ou vazia (1 ou 0). Aqui está o código:
public static int[,] GenerateArray(int width, int height, bool empty) { int[,] map = new int[width, height]; for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { if (empty) { map[x, y] = 0; } else { map[x, y] = 1; } } } return map; }
Renderização de mapa
Esta função é usada para renderizar um mapa em um mapa de blocos. Fazemos um loop pela largura e altura do mapa, colocando blocos somente quando a matriz no ponto testado tiver o valor 1.
public static void RenderMap(int[,] map, Tilemap tilemap, TileBase tile) {
Atualização do mapa
Esta função é usada apenas para atualizar o mapa e não para renderizar novamente. Graças a isso, podemos usar menos recursos sem redesenhar cada bloco e seus dados.
public static void UpdateMap(int[,] map, Tilemap tilemap)
Perlin de ruído
O ruído Perlin pode ser usado para vários propósitos. Primeiro, podemos usá-lo para criar a camada superior do nosso mapa. Para fazer isso, basta obter um novo ponto usando a posição atual xe seed.
Solução simples
Este método de geração usa a forma mais simples de realização do ruído Perlin na geração de níveis. Podemos usar a função Unity para ruído Perlin, para não escrevermos o código. Também usaremos apenas números inteiros para o mapa de
blocos , usando a função
Mathf.FloorToInt () .
public static int[,] PerlinNoise(int[,] map, float seed) { int newPoint;
Aqui está o que parece depois da renderização em um mapa de blocos:
Suavização
Você também pode pegar esta função e suavizá-la. Defina intervalos para fixar as alturas de Perlin e execute a suavização entre esses pontos. Essa função será um pouco mais complicada, pois para intervalos você precisa considerar listas de valores inteiros.
public static int[,] PerlinNoiseSmooth(int[,] map, float seed, int interval) {
Na primeira parte desta função, primeiro verificamos se o intervalo é maior que um. Nesse caso, gere ruído. A geração é realizada em intervalos para que a suavização possa ser aplicada. A próxima parte da função é suavizar os pontos.
A suavização é realizada da seguinte maneira:
- Temos a posição atual e a última
- Temos a diferença entre dois pontos, a informação mais importante que precisamos é a diferença ao longo do eixo y
- Em seguida, determinamos o quanto a alteração precisa ser feita para chegar ao ponto; isso é feito dividindo a diferença em y pela variável de intervalo.
- Em seguida, começamos a definir posições, indo até zero
- Quando atingirmos 0 no eixo y, adicione a mudança de altura à altura atual e repita o processo para a próxima posição x
- Terminando com cada posição entre a última e a atual, passamos para o próximo ponto
Se o intervalo for menor que um, simplesmente usaremos a função anterior, que fará todo o trabalho por nós.
else {
Vamos dar uma olhada na renderização:
Passeio aleatório
Passeio aleatório
Esse algoritmo executa um lançamento de moeda. Podemos obter um de dois resultados. Se o resultado for “águia”, subimos um bloco para cima, se o resultado for “coroa”, então movemos o bloco para baixo. Isso cria alturas, movendo-se constantemente para cima ou para baixo. A única desvantagem de um algoritmo desse tipo é o seu bloqueio muito perceptível. Vamos dar uma olhada em como isso funciona.
public static int[,] RandomWalkTop(int[,] map, float seed) {
Parte superior aleatória da caminhada com anti-aliasingEssa geração nos proporciona alturas mais suaves em comparação com a geração de ruído Perlin.
Essa variação do Random Walk fornece um resultado muito mais suave em comparação com a versão anterior. Podemos implementá-lo adicionando mais duas variáveis à função:
- A primeira variável é usada para determinar quanto tempo leva para manter a altura atual. É inteiro e é redefinido quando a altura muda
- A segunda variável é inserida na função e é usada como a largura mínima da seção para altura. Tornar-se-á mais claro quando examinarmos a função.
Agora sabemos o que adicionar. Vamos dar uma olhada na função:
public static int[,] RandomWalkTopSmoothed(int[,] map, float seed, int minSectionWidth) {
Como você pode ver no gif mostrado abaixo, suavizar o algoritmo de passeio aleatório permite obter belos segmentos planos no nível.
Conclusão
Espero que este artigo o inspire a usar a geração procedural em seus projetos. Se você quiser saber mais sobre mapas gerados proceduralmente, explore os excelentes recursos do
Wiki de Geração de Procedimento ou
Roguebasin.com .
Na segunda parte do artigo, usaremos a geração procedural para criar sistemas de cavernas.
Parte 2
Tudo o que discutiremos nesta parte pode ser encontrado
neste projeto . Você pode baixar ativos e experimentar seus próprios algoritmos processuais.
Perlin de ruído
Na parte anterior, vimos maneiras de aplicar o
ruído Perlin para criar as camadas superiores. Felizmente, o ruído de Perlin também pode ser usado para criar uma caverna. Isso é realizado calculando o novo valor de ruído Perlin, que recebe os parâmetros da posição atual multiplicados pelo modificador. O modificador é um valor de 0 a 1. Quanto maior o valor do modificador, mais caótica será a geração do Perlin. Em seguida, arredondamos esse valor para o número inteiro (0 ou 1), que armazenamos na matriz do mapa. Veja como isso é implementado:
public static int[,] PerlinNoiseCave(int[,] map, float modifier, bool edgesAreWalls) { int newPoint; for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { if (edgesAreWalls && (x == 0 || y == 0 || x == map.GetUpperBound(0) - 1 || y == map.GetUpperBound(1) - 1)) { map[x, y] = 1;
Usamos o modificador em vez da semente porque os resultados da geração de Perlin parecem melhores quando multiplicados por um número de 0 a 0,5. Quanto menor o valor, mais irregular será o resultado. Dê uma olhada nos resultados da amostra. O GIF começa com um valor modificador de 0,01 e atinge incrementalmente um valor de 0,25.
A partir deste gif, pode-se ver que a geração Perlin a cada incremento simplesmente aumenta o padrão.
Passeio aleatório
Na parte anterior, vimos que você pode usar um sorteio para determinar onde a plataforma se moverá para cima ou para baixo. Nesta parte, usaremos a mesma ideia, mas
com duas opções adicionais para deslocamento à esquerda e à direita. Essa variação do algoritmo Random Walk nos permite criar cavernas. Para fazer isso, selecionamos uma direção aleatória, depois movemos nossa posição e excluímos o bloco. Continuamos esse processo até atingir o número necessário de peças que precisam ser destruídas. Até agora, usamos apenas 4 direções: cima, baixo, esquerda, direita.
public static int[,] RandomWalkCave(int[,] map, float seed, int requiredFloorPercent) {
A função começa com o seguinte:
- Encontre a posição inicial
- Calcule o número de pisos a serem excluídos.
- Exclua o bloco na posição inicial
- Adicione um ao número de peças.
Então passamos para o
while
. Ele criará uma caverna:
while (floorCount < reqFloorAmount) {
O que estamos fazendo aqui?
Bem, primeiro, com a ajuda de um número aleatório, escolhemos a direção a seguir. Em seguida, verificamos a nova direção com a
switch case
. Nesta declaração, verificamos se a posição é uma parede. Caso contrário, exclua o elemento com o bloco da matriz. Continuamos a fazer isso até atingir a área desejada. O resultado é mostrado abaixo:
Eu também criei minha própria versão dessa função, que também inclui instruções diagonais. O código da função é bastante longo; portanto, se você quiser vê-lo, faça o download do projeto no link no início desta parte do artigo.
Túnel direcional
Um túnel direcional começa em uma borda do mapa e atinge a borda oposta. Podemos controlar a curvatura e a rugosidade do túnel passando-os para a função de entrada. Também podemos definir o comprimento mínimo e máximo das partes do túnel. Vamos dar uma olhada na implementação:
public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness) {
O que está havendo?
Primeiro, definimos o valor da largura. O valor da largura passará do valor com menos para positivo. Graças a isso, obteremos o tamanho necessário. Nesse caso, usamos o valor 1, que por sua vez nos dará uma largura total de 3, porque usamos os valores -1, 0, 1.
Em seguida, definimos a posição inicial em x, para isso ocupamos o meio da largura do mapa. Depois disso, podemos colocar um túnel na primeira parte do mapa.
Agora vamos entrar no resto do mapa.
Geramos um número aleatório para comparação com o valor da rugosidade e, se for maior que esse valor, a largura do caminho pode ser alterada. Também verificamos o valor para não tornar a largura muito pequena. Na próxima parte do código, percorremos o mapa. Em cada estágio, ocorre o seguinte:
- Geramos um novo número aleatório comparado com o valor da curvatura. Como no teste anterior, se for maior que o valor, alteramos o ponto central do caminho. Também realizamos uma verificação para não ir além do mapa.
- Finalmente, estamos colocando um túnel na parte recém-criada.
Os resultados desta implementação são assim:
Autômatos celulares
Autômatos celulares usam células vizinhas para determinar se a célula atual está ativada (1) ou desativada (0). A base para determinar as células vizinhas é criada com base em uma grade de células gerada aleatoriamente.
Geraremos essa grade de origem usando a função C #
Random.Next .
Como temos algumas implementações diferentes de autômatos celulares, escrevi uma função separada para gerar essa grade básica. A função fica assim:
public static int[,] GenerateCellularAutomata(int width, int height, float seed, int fillPercent, bool edgesAreWalls) {
Nesta função, você também pode definir se nossa grade precisa de paredes. Em todos os outros aspectos, é bastante simples. Verificamos um número aleatório com porcentagem de preenchimento para determinar se a célula atual está ativada. Veja o resultado:
O bairro de Moore
O bairro de Moore é usado para suavizar a geração inicial de autômatos celulares. O bairro de Moore fica assim:
As seguintes regras se aplicam ao bairro:
- Verificamos o vizinho em cada uma das direções.
- Se o vizinho for um bloco ativo, adicione um ao número de blocos circundantes.
- Se o vizinho é um bloco inativo, não fazemos nada.
- Se uma célula tiver mais de 4 blocos adjacentes, ative a célula.
- Se a célula tiver exatamente 4 blocos adjacentes, não faremos nada com ela.
- Repita até verificarmos cada bloco do mapa.
A função de verificação de vizinhança de Moore é a seguinte:
static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls) { int tileCount = 0; for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++) { for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++) { if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1)) {
Depois de verificar o ladrilho, usamos essas informações na função de suavização. Aqui, como na geração de autômatos celulares, pode-se indicar se as bordas do mapa devem ser paredes.
public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount) { for (int i = 0; i < smoothCount; i++) { for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls); if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1))) {
É importante observar aqui que a função possui um loop
for
que executa a suavização do número especificado de vezes. Graças a isso, um cartão mais bonito é obtido.
Sempre podemos modificar esse algoritmo conectando salas se, por exemplo, houver apenas dois blocos entre elas.
Bairro Von Neumann
O bairro de von Neumann é outra maneira popular de implementar autômatos celulares. Para essa geração, usamos um bairro mais simples do que na geração Moore. O bairro fica assim:
As seguintes regras se aplicam ao bairro:
- Verificamos os vizinhos imediatos do bloco, sem considerar os diagonais.
- Se a célula estiver ativa, adicione um à quantidade.
- Se a célula estiver inativa, não faça nada.
- Se a célula tiver mais de 2 vizinhos, ativamos a célula atual.
- Se a célula tiver menos de 2 vizinhos, tornaremos a célula atual inativa.
- Se houver exatamente 2 vizinhos, não altere a célula atual.
O segundo resultado usa os mesmos princípios que o primeiro, mas expande a área do bairro.
Verificamos vizinhos com a seguinte função: static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls) { int tileCount = 0;
Tendo recebido o número de vizinhos, podemos prosseguir para suavizar a matriz. Como antes, precisamos de um loop for
para concluir o número de iterações de suavização passadas para a entrada. public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount) { for (int i = 0; i < smoothCount; i++) { for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) {
Como você pode ver abaixo, o resultado final é muito mais complicado do que o bairro de Moore:Aqui, como nas proximidades de Moore, você pode executar um script adicional para otimizar as conexões entre as partes do mapa.Conclusão
Espero que este artigo o inspire a usar algum tipo de geração processual em seus projetos. Se você não fez o download do projeto, pode obtê-lo aqui .