Mapas hexagonais no Unity: ciclo da água, erosão, biomas, mapa cilíndrico

Partes 1-3: malha, cores e altura das células

Partes 4-7: solavancos, rios e estradas

Peças 8-11: água, formas terrestres e muralhas

Peças 12-15: salvar e carregar, texturas, distâncias

Partes 16-19: encontrando o caminho, esquadrões de jogadores, animações

Partes 20-23: Nevoeiro da Guerra, Pesquisa de Mapas, Geração de Procedimentos

Partes 24-27: ciclo da água, erosão, biomas, mapa cilíndrico

Parte 24: regiões e erosão


  • Adicione uma borda de água ao redor do mapa.
  • Dividimos o mapa em várias regiões.
  • Usamos a erosão para cortar falésias.
  • Nós movemos a terra para suavizar o alívio.

Na parte anterior, lançamos as bases para a geração de mapas procedurais. Desta vez, limitaremos os locais de possível ocorrência de terra e agiremos sobre ela com erosão.

Este tutorial foi criado no Unity 2017.1.0.


Separe e alise a terra.

Borda do mapa


Como aumentamos as áreas de terra aleatoriamente, pode acontecer que a terra toque a borda do mapa. Isso pode ser indesejável. O mapa de água limitada contém uma barreira natural que impede que os jogadores se aproximem da borda. Portanto, seria bom se proibíssemos que a terra subisse acima do nível da água perto da borda do mapa.

Tamanho da borda


Quão perto o terreno deve estar da borda do mapa? Não há resposta certa para essa pergunta, portanto, tornaremos esse parâmetro personalizável. Adicionaremos dois controles deslizantes ao componente HexMapGenerator , um para bordas ao longo das bordas ao longo do eixo X e outro para bordas ao longo do eixo Z. Assim, podemos usar uma borda mais larga em uma das dimensões ou até criar uma borda em apenas uma dimensão. Vamos usar um intervalo de 0 a 10 com um valor padrão de 5.

  [Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5; 


Controles deslizantes de bordas.

Limitamos os centros de áreas terrestres


Sem bordas, todas as células são válidas. Quando existem limites, as coordenadas de deslocamento mínimas permitidas aumentam e as coordenadas máximas permitidas diminuem. Como para gerar os gráficos, precisaremos conhecer o intervalo permitido, vamos controlá-lo usando quatro campos inteiros.

  int xMin, xMax, zMin, zMax; 

Inicializamos as restrições no GenerateMap antes de criar o sushi. Usamos esses valores como parâmetros para chamadas Random.Range , para que os altos sejam realmente excepcionais. Sem uma borda, eles são iguais ao número de células de medição, portanto, não menos 1.

  public void GenerateMap (int x, int z) { … for (int i = 0; i < cellCount; i++) { grid.GetCell(i).WaterLevel = waterLevel; } xMin = mapBorderX; xMax = x - mapBorderX; zMin = mapBorderZ; zMax = z - mapBorderZ; CreateLand(); … } 

Não proibiremos estritamente a aparência de terras além da fronteira, porque isso criaria bordas bem cortadas. Em vez disso, limitaremos apenas as células usadas para iniciar a geração de plotagens. Ou seja, os centros aproximados dos locais serão limitados, mas partes dos locais poderão ir além da área de fronteira. Isso pode ser feito modificando GetRandomCell para selecionar uma célula no intervalo de compensações permitidas.

  HexCell GetRandomCell () { // return grid.GetCell(Random.Range(0, cellCount)); return grid.GetCell(Random.Range(xMin, xMax), Random.Range(zMin, zMax)); } 





As bordas do mapa são 0 × 0, 5 × 5, 10 × 10 e 0 × 10.

Quando todos os parâmetros do mapa são definidos com seus valores padrão, uma borda do tamanho 5 protege de maneira confiável a borda do mapa do contato com a terra. No entanto, isso não é garantido. Às vezes, a terra pode chegar perto da borda e, às vezes, tocá-la em vários lugares.

A probabilidade de a terra cruzar toda a borda depende do tamanho da borda e do tamanho máximo do site. Sem hesitar, as seções permanecem hexágonos. Hexágono completo com raio rcontém 3r2+3r+1células. Se houver hexágonos com um raio igual ao tamanho da borda, eles poderão cruzá-lo. Um hexágono completo com um raio de 5 contém 91 células. Como, por padrão, o máximo é de 100 células por seção, isso significa que o terreno poderá estabelecer uma ponte entre 5 células, especialmente se houver vibrações. Para impedir que isso aconteça, reduza o tamanho máximo da plotagem ou aumente o tamanho da borda.

Como é derivada a fórmula para o número de células na região hexagonal?
Com um raio de 0, estamos lidando com uma única célula. Veio de 1. Com um raio de 1 em torno do centro, existem seis células adicionais, ou seja 6+1. Essas seis células podem ser consideradas as extremidades de seis triângulos tocando o centro. Com um raio de 2, uma segunda linha é adicionada a esses triângulos, ou seja, mais duas células são obtidas no triângulo e, no total, 6(1+2)+1. Com um raio de 3, uma terceira linha é adicionada, ou seja, mais três células por triângulo e no total 6(1+2+3)+1. E assim por diante Ou seja, em termos gerais, a fórmula parece 6(soma(i=1)ri)+1=6((r(r+1))/2)+1=3r(r+1)+1=3r2+3r+1.

Para ver isso mais claramente, podemos definir o tamanho da borda como 200. Como um hexágono completo com um raio de 8 contém 217 células, é provável que o terreno toque a borda do mapa. Pelo menos se você usar o valor padrão do tamanho da borda (5). Se você aumentar a borda para 10, a probabilidade diminuirá bastante.



O terreno tem um tamanho constante de 200, as bordas do mapa são 5 e 10.

Pangea


Observe que, quando você aumenta a borda do mapa e mantém a mesma porcentagem de terra, forçamos a terra a formar uma área menor. Como resultado disso, é muito provável que um mapa grande, por padrão, crie uma única grande massa de terra - o supercontinente Pangea - possivelmente com várias pequenas ilhas. Com um aumento no tamanho da fronteira, a probabilidade disso aumenta e, em certos valores, estamos quase garantidos para obter um supercontinente. No entanto, quando a porcentagem de terra é muito grande, a maioria das áreas disponíveis é preenchida e, como resultado, obtemos uma massa quase retangular de terra. Para impedir que isso aconteça, você precisa reduzir a porcentagem de terra.


Sushi de 40% com uma borda de cartão de 10.

De onde veio o nome Pangea?
Esse era o nome do último supercontinente conhecido que existia na Terra há muitos anos. O nome é composto pelas palavras gregas pan e Gaia, que significam algo como "toda a natureza" ou "toda a terra".


Protegemos de cartões impossíveis


Geramos a quantidade certa de terra simplesmente continuando a elevar a terra até atingirmos a massa desejada. Isso funciona porque, mais cedo ou mais tarde, elevaremos cada célula no nível da água. No entanto, ao usar a borda do mapa, não podemos alcançar todas as células. Quando uma porcentagem muito alta de terra é necessária, isso leva a inúmeras tentativas e falhas do gerador para elevar mais terra e fica preso em um ciclo sem fim. Nesse caso, o aplicativo irá congelar, mas isso não deve acontecer.

Não podemos encontrar com segurança configurações impossíveis com antecedência, mas podemos nos proteger de ciclos intermináveis. Simplesmente rastrearemos o número de ciclos executados no CreateLand . Se houver muitas iterações, provavelmente estamos travados e devemos parar.

Para um mapa grande, mil iterações parecem aceitáveis ​​e dez mil iterações já parecem absurdas. Então, vamos usar esse valor como um ponto de terminação.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); // while (landBudget > 0) { for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); … } } 

Se obtivermos um mapa danificado, a execução de 10.000 iterações não levará muito tempo, porque muitas células atingirão rapidamente a altura máxima, o que impedirá o crescimento de novas áreas.

Mesmo depois de quebrar o ciclo, ainda temos o mapa certo. Ele simplesmente não tem a quantidade certa de sushi e não parecerá muito interessante. Vamos exibir uma notificação sobre isso no console, informando o terreno restante que não conseguimos gastar.

  void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } } 


95% das terras com uma borda de cartão de 10 não podiam gastar a quantia inteira.

Por que um cartão com falha ainda tem variação?
O litoral tem variabilidade, porque quando as alturas dentro da área de criação se tornam muito altas, novas áreas não permitem que elas cresçam para fora. O mesmo princípio não permite que as parcelas se transformem em pequenas áreas de terra, até atingirem a altura máxima e simplesmente desaparecerem. Além disso, a variabilidade aumenta ao diminuir as parcelas.

unitypackage

Particionando um cartão


Agora que temos a borda do mapa, basicamente dividimos o mapa em duas regiões separadas: a região da borda e a região em que os gráficos foram criados. Como apenas a região de criação é importante para nós, podemos considerar esse caso uma situação com uma região. A região simplesmente não cobre o mapa inteiro. Mas se isso for impossível, nada nos impede de dividir o mapa em várias regiões desconectadas da criação de terras. Isso permitirá que as massas de terra se formem independentemente uma da outra, designando diferentes continentes.

Região do mapa


Vamos começar descrevendo uma região do mapa como uma estrutura. Isso simplificará nosso trabalho com várias regiões. Vamos criar uma estrutura MapRegion para isso, que simplesmente contém os campos de borda da região. Como não usaremos essa estrutura fora do HexMapGenerator , podemos defini-la dentro desta classe como uma estrutura interna privada. Em seguida, quatro campos inteiros podem ser substituídos por um campo MapRegion .

 // int xMin, xMax, zMin, zMax; struct MapRegion { public int xMin, xMax, zMin, zMax; } MapRegion region; 

Para que tudo funcione, precisamos adicionar o prefixo da region. aos campos mínimo-máximo no GenerateMap region. .

  region.xMin = mapBorderX; region.xMax = x - mapBorderX; region.zMin = mapBorderZ; region.zMax = z - mapBorderZ; 

E também no GetRandomCell .

  HexCell GetRandomCell () { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); } 

Várias regiões


Para oferecer suporte a várias regiões, substitua um campo MapRegion lista de regiões.

 // MapRegion region; List<MapRegion> regions; 

Nesse momento, seria bom adicionar um método separado para criar regiões. Ele deve criar a lista desejada ou limpá-la, se ela já existir. Depois disso, ele determinará uma região, como fizemos anteriormente, e a adicionará à lista.

  void CreateRegions () { if (regions == null) { regions = new List<MapRegion>(); } else { regions.Clear(); } MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } 

Vamos chamar esse método no GenerateMap e não criaremos a região diretamente.

 // region.xMin = mapBorderX; // region.xMax = x - mapBorderX; // region.zMin = mapBorderZ; // region.zMax = z - mapBorderZ; CreateRegions(); CreateLand(); 

Para que GetRandomCell possa trabalhar com uma região arbitrária, forneça o parâmetro MapRegion .

  HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); } 

Agora os SinkTerrain e SinkTerrain devem passar a região correspondente para GetRandomCell . Para fazer isso, cada um deles também precisa de um parâmetro de região.

  int RaiseTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … } int SinkTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … } 

O método CreateLand deve determinar para cada região aumentar ou diminuir as seções. Para equilibrar a terra entre as regiões, simplesmente percorreremos repetidamente a lista de regiões do ciclo.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (Random.value < sinkProbability) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); } } } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } } 

No entanto, ainda precisamos fazer a redução das parcelas uniformemente distribuídas. Isso pode ser feito ao decidir para todas as regiões se deve omiti-las.

  for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { bool sink = Random.value < sinkProbability; for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); // if (Random.value < sinkProbability) { if (sink) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); } } } 

Finalmente, para usar exatamente toda a quantidade de terra, precisamos interromper o processo assim que a quantidade chegar a zero. Isso pode acontecer em qualquer estágio do ciclo da região. Portanto, movemos a verificação de soma zero para o loop interno. De fato, só podemos realizar essa verificação depois de levantar o terreno, porque ao diminuir, o valor nunca é gasto. Se terminarmos, podemos sair imediatamente do método CreateLand .

 // for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { for (int guard = 0; guard < 10000; guard++) { bool sink = Random.value < sinkProbability; for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (sink) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); if (landBudget == 0) { return; } } } } 

Duas regiões


Embora agora tenhamos o apoio de várias regiões, ainda pedimos apenas uma. Vamos alterar o CreateRegions para que ele divida o mapa ao meio verticalmente. Para fazer isso, xMax pela metade o valor xMax da região adicionada. Em seguida, usamos o mesmo valor para xMin e novamente usamos o valor original para xMax , usando-o como a segunda região.

  MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); 

A geração de cartões nesta fase não fará nenhuma diferença. Embora tenhamos identificado duas regiões, elas ocupam a mesma região que uma região antiga. Para separá-los, você precisa deixar um espaço vazio entre eles. Isso pode ser feito adicionando um controle deslizante à borda da região, usando o mesmo intervalo e valor padrão das bordas do mapa.

  [Range(0, 10)] public int regionBorder = 5; 


Controle deslizante de borda da região.

Como a terra pode ser formada em ambos os lados do espaço entre as regiões, a probabilidade de criar pontes de terra nas bordas do mapa aumentará. Para evitar isso, usamos a borda da região para definir uma zona livre de terra entre a linha divisória e a região na qual as parcelas podem começar. Isso significa que a distância entre regiões vizinhas é duas vezes maior que o tamanho da fronteira da região.

Para aplicar esse limite de região, subtraia-o do xMax primeira região e adicione a segunda região ao xMin .

  MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); 


O mapa é dividido verticalmente em duas regiões.

Com as configurações padrão, serão criadas duas regiões visivelmente separadas; no entanto, como no caso de uma região e uma grande borda do mapa, não temos garantia de receber exatamente duas massas de terra. Na maioria das vezes, serão dois grandes continentes, possivelmente com várias ilhas. Mas, às vezes, duas ou mais ilhas grandes podem ser criadas em uma região. E, às vezes, dois continentes podem ser conectados por um istmo.

Obviamente, também podemos dividir o mapa horizontalmente, alterando as abordagens para medir X e Z. Vamos escolher aleatoriamente uma das duas orientações possíveis.

  MapRegion region; if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } 


Mapa dividido horizontalmente em duas regiões.

Como usamos um mapa amplo, regiões mais amplas e finas serão criadas com separação horizontal. Como resultado, é mais provável que essas regiões formem várias massas de terra divididas.

Quatro regiões


Vamos personalizar o número de regiões, criar suporte de 1 a 4 regiões.

  [Range(1, 4)] public int regionCount = 1; 


Controle deslizante para o número de regiões.

Podemos usar a switch para selecionar a execução do código de região correspondente. Começamos repetindo o código de uma região, que será usada por padrão, e deixamos o código de duas regiões para o caso 2.

  MapRegion region; switch (regionCount) { default: region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; } 

Qual é a instrução switch?
Essa é uma alternativa para escrever uma sequência de instruções if-else-if-else. A opção é aplicada à variável e os rótulos são usados ​​para indicar qual código precisa ser executado. Há também um rótulo default , que é usado como o último bloco else . Cada opção deve terminar com uma declaração de break ou uma return .

Para manter o bloco de switch legível, geralmente é melhor manter todos os casos curtos, idealmente com uma única instrução ou chamada de método. Não o farei como um exemplo de código de região, mas se você deseja criar regiões mais interessantes, recomendo que você use métodos separados. Por exemplo:

  switch (regionCount) { default: CreateOneRegion(); break; case 2: CreateTwoRegions(); break; case 3: CreateThreeRegions(); break; case 4: CreateFourRegions(); break; } 

Três regiões são semelhantes a duas, apenas terços são usados ​​em vez da metade. Nesse caso, a divisão horizontal criará regiões muito estreitas; portanto, criamos suporte apenas para a divisão vertical. Observe que, como resultado, dobramos a área de borda da região, portanto, o espaço para a criação de novas seções é menor do que no caso de duas regiões.

  switch (regionCount) { default: … break; case 2: … break; case 3: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 3 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 3 + regionBorder; region.xMax = grid.cellCountX * 2 / 3 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX * 2 / 3 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); break; } 


Três regiões

É possível criar quatro regiões combinando a separação horizontal e vertical e adicionando uma região a cada canto do mapa.

  switch (regionCount) { … case 4: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; regions.Add(region); break; } } 


Quatro regiões.

A abordagem usada aqui é a maneira mais simples de dividir um mapa. Ele gera aproximadamente as mesmas regiões por massa de terra e sua variabilidade é controlada por outros parâmetros de geração de mapas. No entanto, sempre será bastante óbvio que o cartão foi dividido em linhas retas. Quanto mais controle precisamos, menos orgânico será o resultado. Portanto, isso é normal se você precisar de regiões aproximadamente iguais para a jogabilidade. Mas se você precisar da terra mais variada e ilimitada, precisará fazê-la com a ajuda de uma região.

Além disso, existem outras maneiras de dividir o mapa. Não podemos nos limitar apenas a linhas retas. Nem precisamos usar regiões do mesmo tamanho, além de cobrir o mapa inteiro com elas. Nós podemos deixar buracos. Você também pode permitir interseções de regiões ou alterar a distribuição de terras entre regiões. Você pode até definir seus próprios parâmetros de gerador para cada região (embora isso seja mais complicado), por exemplo, para ter um grande continente e um arquipélago no mapa.

unitypackage

Erosão


Até agora, todas as cartas que geramos pareciam rudes e quebradas.Um alívio real pode ser assim, mas com o tempo se torna cada vez mais suave, suas partes afiadas ficam embotadas devido à erosão. Para melhorar os mapas, podemos aplicar esse processo de erosão. Faremos isso depois de criar terrenos acidentados, em um método separado.

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {} 

Percentagem de erosão


Quanto mais o tempo passa, mais erosão aparece. Portanto, queremos que a erosão não seja permanente, mas personalizável. No mínimo, a erosão é zero, o que corresponde aos mapas criados anteriormente. Quando a erosão máxima é abrangente, ou seja, a aplicação posterior das forças de erosão não mudará mais o terreno. Ou seja, o parâmetro erosion deve ser uma porcentagem de 0 a 100 e, por padrão, usaremos 50.

  [Range(0, 100)] public int erosionPercentage = 50; 


Controle deslizante de erosão.

Procurar células destruidoras de erosão


A erosão torna o alívio mais suave. No nosso caso, as únicas partes afiadas são as falésias. Portanto, eles serão o alvo do processo de erosão. Se existir um penhasco, a erosão deve reduzi-lo até que finalmente se transforme em um declive. Não suavizaremos as encostas, porque isso levará a um terreno chato. Para fazer isso, precisamos determinar quais células estão no topo dos penhascos e diminuir sua altura. Estas serão células propensas à erosão.

Vamos criar um método que determine se uma célula pode ser propensa a erosão. Ele determina isso verificando os vizinhos da célula até encontrar uma diferença de altura suficientemente grande. Como as falésias exigem uma diferença de pelo menos um ou dois níveis de altura, a célula está sujeita a erosão se um ou mais de seus vizinhos estiver a pelo menos dois passos abaixo dela. Se não houver tal vizinho, a célula não poderá sofrer erosão.

  bool IsErodible (HexCell cell) { int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { return true; } } return false; } 

Podemos usar esse método ErodeLandpara percorrer todas as células e gravar todas as células propensas à erosão em uma lista temporária.

  void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (IsErodible(cell)) { erodibleCells.Add(cell); } } ListPool<HexCell>.Add(erodibleCells); } 

Depois de conhecer o número total de células propensas à erosão, podemos usar a porcentagem de erosão para determinar o número de células propensas à erosão restantes. Por exemplo, se a porcentagem for 50, precisamos danificar as células até que metade da quantidade original permaneça. Se a porcentagem for 100, não pararemos até destruirmos todas as células propensas à erosão.

  void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); ListPool<HexCell>.Add(erodibleCells); } 

Não devemos considerar apenas as células propensas à erosão da terra?
. , , .

Redução de células


Vamos começar com uma abordagem ingênua e supor que uma simples redução na altura das células destruídas pela erosão deixará de ser mais propensa à erosão. Se isso fosse verdade, poderíamos simplesmente pegar células aleatórias da lista, reduzir sua altura e removê-las da lista. Repetiríamos essa operação até atingirmos o número desejado de células suscetíveis à erosão.

  int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); while (erodibleCells.Count > targetErodibleCount) { int index = Random.Range(0, erodibleCells.Count); HexCell cell = erodibleCells[index]; cell.Elevation -= 1; erodibleCells.Remove(cell); } ListPool<HexCell>.Add(erodibleCells); 

Para impedir a pesquisa necessária erodibleCells.Remove, substituiremos a célula atual por último na lista e excluiremos o último elemento. Ainda não nos importamos com o pedido deles.

 // erodibleCells.Remove(cell); erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); 



Diminuição ingênua de 0% e 100% de células propensas à erosão, mapa de sementes 1957632474.

Rastreamento de erosão


Nossa abordagem ingênua nos permite aplicar erosão, mas não no grau certo. Isso acontece porque a célula após uma diminuição na altura ainda pode permanecer propensa à erosão. Portanto, removeremos uma célula da lista somente quando não estiver mais sujeita a erosão.

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } 


100% de erosão, mantendo as células propensas a erosão na lista.

Portanto, temos uma erosão muito mais forte, mas ao usar 100%, ainda não nos livramos de todos os penhascos. A razão é que, depois de reduzir a altura da célula, um de seus vizinhos pode se tornar propenso a erosão. Portanto, como resultado, podemos ter mais células propensas à erosão do que originalmente.

Depois de baixar a célula, precisamos verificar todos os seus vizinhos. Se agora eles estão propensos à erosão, mas ainda não estão na lista, é necessário adicioná-los lá.

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && IsErodible(neighbor) && !erodibleCells.Contains(neighbor) ) { erodibleCells.Add(neighbor); } } 


Todas as células erodidas são omitidas.

Economizamos muita terra


Agora o processo de erosão pode continuar até que todos os penhascos desapareçam. Isso afeta muito a terra. A maior parte da massa de terra desapareceu e temos muito menos do que a porcentagem de terra necessária. Aconteceu porque estamos removendo terras do mapa.

A verdadeira erosão não destrói a matéria. Ela pega de um lugar e coloca em outro lugar. Nós podemos fazer o mesmo. Com uma diminuição em uma célula, devemos criar um de seus vizinhos. De fato, um nível de altura é transferido para uma célula inferior. Isso economiza a quantidade total de alturas do mapa, enquanto a suaviza.

Para isso, precisamos decidir para onde transferir os produtos erosivos. Este será o nosso objetivo de erosão. Vamos criar um método para determinar o ponto de destino de uma célula a ser corroída. Como essa célula contém uma interrupção, seria lógico selecionar a célula localizada nessa interrupção como destino. Mas uma célula propensa à erosão pode ter várias quebras, portanto, verificamos todos os vizinhos e colocamos todos os candidatos em uma lista temporária, e depois escolhemos um deles aleatoriamente.

  HexCell GetErosionTarget (HexCell cell) { List<HexCell> candidates = ListPool<HexCell>.Get(); int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { candidates.Add(neighbor); } } HexCell target = candidates[Random.Range(0, candidates.Count)]; ListPool<HexCell>.Add(candidates); return target; } 

Em ErodeLandnós definimos a célula alvo imediatamente após selecionar a célula de erosão. Então diminuímos e aumentamos a altura das células imediatamente uma após a outra. Nesse caso, a própria célula-alvo pode se tornar suscetível à erosão, mas essa situação é resolvida quando verificamos os vizinhos da nova célula erodida.

  HexCell cell = erodibleCells[index]; HexCell targetCell = GetErosionTarget(cell); cell.Elevation -= 1; targetCell.Elevation += 1; if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } 

Como elevamos a célula alvo, parte dos vizinhos dessa célula pode não estar mais sujeita a erosão. É necessário contorná-los e verificar se eles estão propensos à erosão. Caso contrário, mas eles estão na lista, você precisará removê-los.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); … } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } } 


100% de erosão, mantendo a massa da terra.

Agora, a erosão pode suavizar muito melhor o terreno, diminuindo algumas áreas e elevando outras. Como resultado, a massa de terra pode aumentar e diminuir. Isso pode alterar a porcentagem de terra em vários por cento em uma direção ou outra, mas raramente ocorrem desvios sérios. Ou seja, quanto mais erosão aplicarmos, menos controle teremos sobre a porcentagem resultante de terra.

Erosão acelerada


Embora não precisemos realmente nos preocupar com a eficácia do algoritmo de erosão, podemos fazer melhorias simples nele. Primeiro, observe que verificamos explicitamente se a célula que erodimos pode ser erodida. Caso contrário, então essencialmente o removemos da lista. Portanto, você pode pular a verificação dessa célula ao percorrer os vizinhos da célula de destino.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } } 

Em segundo lugar, precisávamos verificar os vizinhos da célula de destino apenas quando havia um intervalo entre eles, mas agora isso não é necessário. Isso só acontece quando o vizinho está agora um passo mais alto que a célula de destino. Nesse caso, é garantido que o vizinho esteja na lista; portanto, não precisamos verificar isso, ou seja, podemos pular a pesquisa desnecessária.

  HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && neighbor.Elevation == targetCell.Elevation + 1 && !IsErodible(neighbor) // && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } 

Em terceiro lugar, podemos usar um truque semelhante ao verificar os vizinhos de uma célula propensa a erosão. Se agora existe um penhasco entre eles, o vizinho está propenso à erosão. Para descobrir, não precisamos ligar IsErodible.

  HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 && // IsErodible(neighbor) && !erodibleCells.Contains(neighbor) ) { erodibleCells.Add(neighbor); } 

No entanto, ainda precisamos verificar se a célula alvo é suscetível à erosão, mas o ciclo mostrado acima não está mais fazendo isso. Portanto, executamos isso explicitamente para a célula de destino.

  if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (IsErodible(targetCell) && !erodibleCells.Contains(targetCell)) { erodibleCells.Add(targetCell); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } 

Agora podemos aplicar a erosão com rapidez suficiente e com a porcentagem desejada em relação ao número inicial de falésias geradas. Observe que, devido ao fato de termos mudado levemente o local onde a célula de destino é adicionada à lista propensa à erosão, o resultado foi ligeiramente alterado em relação ao resultado antes das otimizações.





25%, 50%, 75% e 100% de erosão.

Observe também que, apesar da forma alterada da costa, a topologia não mudou fundamentalmente. As massas de terra geralmente permanecem conectadas ou separadas. Somente pequenas ilhas podem se afogar completamente. Os detalhes do relevo são suavizados, mas as formas gerais permanecem as mesmas. Uma articulação estreita pode desaparecer ou crescer um pouco. Um pequeno espaço pode preencher ou expandir um pouco. Portanto, a erosão não ficará fortemente unida às regiões divididas.


Quatro regiões completamente erodidas ainda permanecem separadas.

unitypackage

Parte 25: O Ciclo da Água


  • Exibir dados brutos do mapa.
  • Nós formamos um clima de células.
  • Crie uma simulação parcial do ciclo da água.

Nesta parte, adicionaremos umidade à terra.

Este tutorial foi criado no Unity 2017.3.0.


Usamos o ciclo da água para determinar os biomas.

As nuvens


Até esse momento, o algoritmo de geração de mapas alterava apenas a altura da célula. A maior diferença entre as células era se elas estavam acima ou abaixo da água. Embora possamos definir diferentes tipos de terreno, essa é apenas uma visualização simples da altura. Será melhor especificar os tipos de alívio, dado o clima local.

O clima da Terra é um sistema muito complexo. Felizmente, não precisamos criar simulações climáticas realistas. Vamos precisar de algo que pareça natural o suficiente. O aspecto mais importante do clima é o ciclo da água, porque a flora e a fauna precisam de água líquida para sobreviver. A temperatura também é muito importante, mas, por enquanto, nos concentramos na água, essencialmente deixando a temperatura global constante e alterando apenas a umidade.

O ciclo da água descreve o movimento da água no meio ambiente. Simplificando, as lagoas evaporam, o que leva à criação de nuvens que chovem, que novamente fluem para as lagoas. Existem muitos outros aspectos no sistema, mas a simulação dessas etapas já pode ser suficiente para criar uma distribuição natural da água no mapa.

Visualização de dados


Antes de entrarmos nesta simulação, será útil ver diretamente os dados relevantes. Para fazer isso, mudaremos o shader Terrain . Nós adicionamos uma propriedade selecionável a ele, que pode ser alternada para o modo de visualização de dados, que exibe dados brutos do mapa em vez das texturas de alívio usuais. Isso pode ser implementado usando uma propriedade float com um atributo comutável que define a palavra-chave. Por esse motivo, ele aparecerá no inspetor de materiais como um sinalizador que controla a definição de uma palavra-chave. O nome da propriedade em si não é importante, estamos interessados ​​apenas na palavra-chave. Estamos usando SHOW_MAP_DATA .

  Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0 } 


Alterne para exibir dados do mapa.

Adicione uma função de sombreador para ativar o suporte a palavras-chave.

  #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA 

Vamos fazer com que ele exiba um único flutuador, como é o caso dos demais dados de alívio. Para implementar isso, adicionaremos um Inputcampo à estrutura mapDataquando a palavra-chave for definida.

  struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility; #if defined(SHOW_MAP_DATA) float mapData; #endif }; 

No programa de vértices, usamos o canal Z dessas células para preencher mapData, como sempre interpolado entre as células.

  void vert (inout appdata_full v, out Input data) { … #if defined(SHOW_MAP_DATA) data.mapData = cell0.z * v.color.x + cell1.z * v.color.y + cell2.z * v.color.z; #endif } 

Quando você precisar exibir dados da célula, use-os diretamente como um fragmento de albedo em vez da cor usual. Multiplique-o pela grade para que a grade ainda esteja ativada ao renderizar os dados.

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … o.Albedo = c.rgb * grid * _Color * explored; #if defined(SHOW_MAP_DATA) o.Albedo = IN.mapData * grid; #endif … } 

Para realmente transferir dados para um sombreador. precisamos adicionar ao HexCellShaderDatamétodo que escreve algo no canal de dados de textura azul. Os dados são um único valor flutuante limitado a 0–1.

  public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; } 

No entanto, essa decisão afeta o sistema de pesquisa. Um valor de dados de canal azul 255 é usado para indicar que a visibilidade da célula está em transição. Para que esse sistema continue funcionando, precisamos usar o valor de byte 254 como máximo. Observe que o movimento do destacamento apagará todos os dados do cartão, mas isso nos convém, porque eles são usados ​​para a geração de cartões de depuração.

  cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254); 

Adicione um método com o mesmo nome e em HexCell. Ele transferirá a solicitação para seus dados de sombreador.

  public void SetMapData (float data) { ShaderData.SetMapData(this, data); } 

Para verificar a operação do código, altere-o HexMapGenerator.SetTerrainTypepara que ele defina os dados de cada célula do mapa. Vamos visualizar a altura convertida de número inteiro para flutuar no intervalo de 0 a 1. Isso é feito subtraindo a altura mínima da altura da célula, seguida pela divisão da altura máxima menos a mínima. Vamos fazer o ponto flutuante da divisão.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } } 

Agora podemos alternar entre o terreno normal e a visualização de dados usando a caixa de seleção Mostrar dados do mapa do ativo de material do terreno .



Mapa 1208905299, terreno normal e visualização de alturas.

Criação climática


Para simular o clima, precisamos rastrear dados climáticos. Como o mapa consiste em células discretas, cada uma delas tem seu próprio clima local. Crie uma estrutura ClimateDatapara armazenar todos os dados relevantes. Obviamente, você pode adicionar dados às próprias células, mas nós os usaremos apenas ao gerar o mapa. Portanto, vamos salvá-los separadamente. Isso significa que podemos definir essa estrutura internamente HexMapGenerator, como MapRegion. Começaremos rastreando apenas as nuvens, que podem ser implementadas usando um único campo flutuante.

  struct ClimateData { public float clouds; } 

Adicione uma lista para rastrear dados climáticos para todas as células.

  List<ClimateData> climate = new List<ClimateData>(); 

Agora precisamos de um método para criar um mapa climático. Deve começar limpando a lista de zonas climáticas e, em seguida, adicione um elemento para cada célula. Os dados climáticos iniciais são simplesmente zero, isso pode ser alcançado usando um construtor padrão ClimateData.
  void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } } 

O clima deve ser criado após a exposição à erosão da terra antes de definir os tipos de relevo. Na realidade, a erosão é causada principalmente pelo movimento do ar e da água, que fazem parte do clima, mas não simularemos isso.

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … } 

Altere SetTerrainTypepara que possamos ver dados da nuvem em vez da altura da célula. Inicialmente, parecerá um cartão preto.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } } 

Mudança climática


O primeiro passo na simulação climática é a evaporação. Quanta água deve evaporar? Vamos controlar esse valor usando o controle deslizante. Um valor 0 significa que não há evaporação, 1 - evaporação máxima. Por padrão, usamos 0,5.

  [Range(0f, 1f)] public float evaporation = 0.5f; 


Controle deslizante de evaporação.

Vamos criar outro método especificamente para moldar o clima de uma célula. Fornecemos o índice de células como parâmetro e o usamos para obter a célula correspondente e seus dados climáticos. Se a célula estiver submersa, estamos lidando com um reservatório que deve evaporar. Transformamos imediatamente o vapor em nuvens (ignorando os pontos de condensação e condensação), para adicionar diretamente a evaporação ao valor das nuvens celulares. Quando terminar, copie os dados climáticos de volta para a lista.

  void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; } 

Chame esse método para cada célula CreateClimate.

  void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } 

Mas isso não é suficiente. Para criar uma simulação complexa, precisamos moldar o clima das células várias vezes. Quanto mais fazemos isso, melhor será o resultado. Vamos apenas escolher um valor constante. Eu uso 40 ciclos.

  for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } } 

Como embora apenas aumentemos o valor das nuvens acima das células inundadas com água, como resultado, obtemos terra preta e reservatórios brancos.


Evaporação sobre a água.

Espalhamento de nuvens


As nuvens não estão constantemente em um só lugar, especialmente quando mais e mais água evapora. A diferença de pressão faz o ar se mover, que se manifesta na forma de vento, que também faz as nuvens se moverem.

Se não houver direção dominante do vento, em média as nuvens das células se dispersarão uniformemente em todas as direções, aparecendo nas células vizinhas. Ao gerar novas nuvens no próximo ciclo, vamos distribuir todas as nuvens da célula em seus vizinhos. Ou seja, cada vizinho recebe um sexto das nuvens de células, após o que há uma diminuição local para zero.

  if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate; 

Para realmente adicionar nuvens aos seus vizinhos, você precisa contorná-los em loop, obter os dados climáticos, aumentar o valor das nuvens e copiá-los de volta para a lista.

  float cloudDispersal = cellClimate.clouds * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; climate[neighbor.Index] = neighborClimate; } cellClimate.clouds = 0f; 


Nuvens dispersas.

Isso cria um mapa quase branco, porque a cada ciclo as células subaquáticas adicionam mais e mais nuvens ao clima global. Após o primeiro ciclo, as células terrestres próximas à água também terão nuvens que precisam ser dispersas. Esse processo continua até que a maior parte do mapa esteja coberta de nuvens. No caso do mapa 1208905299 com os parâmetros padrão, apenas a parte interna da grande massa de terra no nordeste permaneceu completamente descoberta.

Observe que lagoas podem gerar um número infinito de nuvens. O nível da água não faz parte da simulação climática. Na realidade, os reservatórios são preservados apenas porque a água flui de volta para eles aproximadamente à taxa de evaporação. Ou seja, simulamos apenas um ciclo parcial da água. Isso é normal, mas precisamos entender que quanto mais a simulação ocorre, mais água é adicionada ao clima. Até agora, a perda de água ocorre apenas nas bordas do mapa, onde nuvens dispersas são perdidas devido à falta de vizinhos.

Você pode ver a perda de água na parte superior do mapa, especialmente nas células no canto superior direito. Na última célula, não há nuvens, porque continua sendo a última em que o clima é formado. Ela ainda não recebeu nuvens de um vizinho.

O clima de todas as células não deveria se formar em paralelo?
, . - , . 40 . - , .

Precipitação


A água não fica fria para sempre. Em algum momento, ela deve cair no chão novamente. Isso geralmente acontece na forma de chuva, mas às vezes pode ser neve, granizo ou neve molhada. Tudo isso é geralmente chamado de precipitação. A magnitude e a taxa de desaparecimento das nuvens variam muito, mas apenas usamos uma taxa global de precipitação personalizada. Um valor 0 significa que não há precipitação, um valor 1 significa que todas as nuvens desaparecem instantaneamente. O valor padrão é 0,25. Isso significa que em cada ciclo um quarto das nuvens desaparecerá.

  [Range(0f, 1f)] public float precipitationFactor = 0.25f; 


Controle deslizante do coeficiente de precipitação.

Simularemos a precipitação após a evaporação e antes da dispersão das nuvens. Isso significa que parte da água evaporada dos reservatórios precipita imediatamente, diminuindo o número de nuvens dispersas. Sobre a terra, a precipitação levará ao desaparecimento das nuvens.

  if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f); 


Nuvens desaparecendo.

Agora, quando destruímos 25% das nuvens em cada ciclo, a terra fica novamente quase negra. As nuvens conseguem se mover para o interior apenas alguns passos, após o que se tornam invisíveis.

unitypackage

Umidade


Embora a chuva destrua as nuvens, elas não devem remover a água do clima. Depois de cair no chão, a água é salva, apenas em um estado diferente. Pode existir de várias formas, que geralmente consideraremos umidade.

Rastreamento de umidade


Vamos melhorar o modelo climático rastreando duas condições da água: nuvens e umidade. Para conseguir isso, adicionar um ClimateDatacampo moisture.

  struct ClimateData { public float clouds, moisture; } 

Em sua forma mais generalizada, a evaporação é o processo de conversão de umidade em nuvens, pelo menos em nosso modelo climático simples. Isso significa que a evaporação não deve ser um valor constante, mas outro fator. Portanto, realizamos a renomeação de refatoração evaporationpara evaporationFactor.

  [Range(0f, 1f)] public float evaporationFactor = 0.5f; 

Quando a célula está submersa, simplesmente anunciamos que o nível de umidade é 1. Isso significa que a evaporação é igual ao coeficiente de evaporação. Mas agora também podemos obter evaporação das células de sushi. Nesse caso, precisamos calcular a evaporação, subtraí-la da umidade e adicionar o resultado às nuvens. Depois disso, a precipitação é adicionada à umidade.

  if (cell.IsUnderwater) { cellClimate.moisture = 1f; cellClimate.clouds += evaporationFactor; } else { float evaporation = cellClimate.moisture * evaporationFactor; cellClimate.moisture -= evaporation; cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; 

Como as nuvens agora são suportadas pela evaporação acima da terra, podemos movê-las para o interior. Agora a maior parte da terra ficou cinza.


Nuvens com evaporação de umidade.

Vamos alterá-lo SetTerrainTypepara exibir umidade em vez de nuvens, porque vamos usá-lo para determinar os tipos de relevo.

  cell.SetMapData(climate[i].moisture); 


Exibição de umidade.

Nesse ponto, a umidade parece bastante semelhante às nuvens (exceto que todas as células subaquáticas são brancas), mas isso mudará em breve.

Escoamento de chuva


A evaporação não é a única maneira de a umidade sair da célula. O ciclo da água nos diz que a maior parte da umidade adicionada à terra acaba de alguma forma na água. O processo mais notável é o fluxo de água sobre a terra sob a influência da gravidade. Não simularemos rios reais, mas usaremos um coeficiente de escoamento de chuva personalizado. Isso indicará a porcentagem de água drenada para as áreas mais baixas. Vamos usar como padrão o estoque será igual a 25%.

  [Range(0f, 1f)] public float runoffFactor = 0.25f; 


Drene o controle deslizante.

Nós não vamos gerar rios?
.

O escoamento da água atua como uma dispersão de nuvens, mas com três diferenças. Em primeiro lugar, nem toda a umidade é removida da célula. Em segundo lugar, ele carrega umidade, não nuvens. Em terceiro lugar, desce, ou seja, apenas para vizinhos com uma altura mais baixa. O coeficiente de escoamento superficial descreve a quantidade de umidade que sairia da célula se todos os vizinhos fossem mais baixos, mas geralmente são menores. Isso significa que reduziremos a umidade da célula somente quando encontrarmos um vizinho abaixo.

  float cloudDispersal = cellClimate.clouds * (1f / 6f); float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; int elevationDelta = neighbor.Elevation - cell.Elevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } climate[neighbor.Index] = neighborClimate; } 


Drenagem da água a uma altura mais baixa.

Como resultado, temos uma distribuição mais diversificada de umidade, porque células altas transmitem sua umidade para as mais baixas. Também vemos muito menos umidade nas células costeiras, porque elas drenam a umidade para dentro das células subaquáticas. Para enfraquecer esse efeito, também precisamos usar o nível da água para determinar se a célula está mais baixa, ou seja, medir a altura aparente.

  int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; 


Use a altura visível.

Infiltração


A água não apenas flui para baixo, mas também se espalha através da topografia nivelada e é absorvida pela terra adjacente aos corpos d'água. Esse efeito pode ter pouco efeito, mas é útil para suavizar a distribuição de umidade, então vamos adicioná-lo à simulação. Vamos criar seu próprio coeficiente personalizado, por padrão igual a 0,125.

  [Range(0f, 1f)] public float seepageFactor = 0.125f; 


Controle deslizante de vazamento.

A infiltração é semelhante a um dreno, exceto que é usada quando o vizinho tem a mesma altura visível que a própria célula.

  float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); float seepage = cellClimate.moisture * seepageFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } else if (elevationDelta == 0) { cellClimate.moisture -= seepage; neighborClimate.moisture += seepage; } climate[neighbor.Index] = neighborClimate; } 


Adicionado um pouco de vazamento.

unitypackage

Sombras de chuva


Embora já tenhamos criado uma simulação digna do ciclo da água, ele não parece muito interessante, porque não possui sombras de chuva, que demonstram claramente as diferenças climáticas. Sombras de chuva são áreas em que há uma falta significativa de chuva em comparação com as áreas vizinhas. Tais áreas existem porque as montanhas impedem que as nuvens os alcancem. Sua criação requer altas montanhas e uma direção dominante do vento.

O vento


Vamos começar adicionando uma direção dominante do vento à simulação. Embora as direções dominantes do vento variem bastante na superfície da Terra, gerenciaremos com uma direção global personalizável do vento. Vamos usar o noroeste por padrão. Além disso, vamos ajustar a força do vento de 1 a 10 com um valor padrão de 4.

  public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f; 


A direção e força do vento.

A força do vento dominante é expressa em relação à dispersão total das nuvens. Se a força do vento for 1, a dispersão será a mesma em todas as direções. Quando é 2, a dispersão é duas mais alta na direção do vento do que em outras direções, e assim por diante. Podemos fazer isso alterando o divisor na fórmula de dispersão de nuvens. Em vez de seis, será igual a cinco mais energia eólica.

  float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength)); 

Além disso, a direção do vento determina a direção a partir da qual o vento sopra. Portanto, precisamos usar a direção oposta como a direção principal da dispersão.

  HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength)); 

Agora podemos verificar se o vizinho está na direção principal da dispersão. Nesse caso, devemos multiplicar a dispersão das nuvens pela força do vento.

  ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; } 


Vento noroeste, força 4.

O vento dominante acrescenta direcionalidade à distribuição de umidade sobre a terra. Quanto mais forte o vento, mais poderoso o efeito se torna.

Altura absoluta


O segundo ingrediente para obter sombras de chuva são as montanhas. Não temos uma classificação estrita do que é uma montanha, assim como a natureza também não a possui. Somente a altura absoluta é importante. De fato, quando o ar se move sobre a montanha, ele é forçado a subir, é resfriado e pode conter menos água, o que leva à precipitação antes que o ar passe sobre a montanha. Como resultado, do outro lado, temos ar seco, ou seja, uma sombra da chuva.

Mais importante, quanto mais alto o ar sobe, menos água ele pode conter. Em nossa simulação, podemos imaginar isso como uma restrição forçada do valor máximo de nuvem para cada célula. Quanto maior a altura visível da célula, menor será esse máximo. A maneira mais fácil de fazer isso é definir o máximo como 1 menos a altura aparente, dividida pela altura máxima. Mas, de fato, vamos dividir por um máximo de menos 1. Isso permitirá que uma pequena fração das nuvens ainda passe pelas células mais altas. Atribuímos esse valor máximo após o cálculo da precipitação e antes da dispersão.

  float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite(); 

Se, como resultado, obtivermos mais nuvens do que aceitáveis, simplesmente converteremos o excesso de nuvens em umidade. De fato, é assim que adicionamos precipitação adicional, como acontece nas montanhas reais.

  float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; } 


Sombras de chuva causadas por grandes altitudes.

unitypackage

Nós completamos a simulação


Nesta fase, já temos uma simulação parcial de alta qualidade do ciclo da água. Vamos colocá-lo em ordem um pouco e depois aplicá-lo para determinar o tipo de alívio das células.

Computação paralela


Como mencionado anteriormente no spoiler, a ordem em que as células são formadas afeta o resultado da simulação. Idealmente, isso não deveria ser e, em essência, formamos todas as células em paralelo. Isso pode ser feito aplicando todas as mudanças do estágio atual de formação à segunda lista de clima nextClimate.

  List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>(); 

Limpe e inicialize esta lista, como todos os outros. Em seguida, trocaremos listas em cada ciclo. Nesse caso, a simulação usará alternadamente as duas listas e aplicará os dados climáticos atuais e futuros.

  void CreateClimate () { climate.Clear(); nextClimate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(initialData); } for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } List<ClimateData> swap = climate; climate = nextClimate; nextClimate = swap; } } 

Quando uma célula afeta o clima de seu vizinho, devemos alterar os seguintes dados climáticos, não os atuais.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = nextClimate[neighbor.Index]; … nextClimate[neighbor.Index] = neighborClimate; } 

E, em vez de copiar os seguintes dados climáticos de volta à lista climática atual, obtemos os seguintes dados climáticos, adicionamos a umidade atual a eles e copiamos tudo para a próxima lista. Depois disso, redefinimos os dados na lista atual para que sejam atualizados para o próximo ciclo.

 // cellClimate.clouds = 0f; ClimateData nextCellClimate = nextClimate[cellIndex]; nextCellClimate.moisture += cellClimate.moisture; nextClimate[cellIndex] = nextCellClimate; climate[cellIndex] = new ClimateData(); 

Enquanto fazemos isso, vamos também definir o nível de umidade para um máximo de 1, para que as células terrestres não possam estar mais úmidas do que debaixo d'água.

  nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate; 


Computação paralela.

Humidade original


Há uma chance de que a simulação produza muita terra seca, especialmente com uma alta porcentagem de terra. Para melhorar a imagem, podemos adicionar um nível de umidade inicial personalizado com um valor padrão de 0,1.

  [Range(0f, 1f)] public float startingMoisture = 0.1f; 


Acima está o controle deslizante da umidade original.

Usamos esse valor para a umidade da lista climática inicial, mas não para o seguinte.

  ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); } 


Com umidade original.

Definindo biomas


Concluímos usando umidade em vez de altura para especificar o tipo de alívio da célula. Vamos usar a neve para terra completamente seca; em regiões áridas, usamos neve; depois, há pedra, grama para umidade o suficiente e terra para células subaquáticas e saturadas de água. A maneira mais fácil é usar cinco intervalos em incrementos de 0,2.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { if (moisture < 0.2f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.4f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.6f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.8f) { cell.TerrainTypeIndex = 1; } else { cell.TerrainTypeIndex = 2; } } else { cell.TerrainTypeIndex = 2; } cell.SetMapData(moisture); } } 


Biomas.

Ao usar uma distribuição uniforme, o resultado não é muito bom e parece artificial. É melhor usar outros limites, por exemplo, 0,05, 0,12, 0,28 e 0,85.

  if (moisture < 0.05f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.12f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.28f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.85f) { cell.TerrainTypeIndex = 1; } 


Biomas modificados.

unitypackage

Parte 26: biomas e rios


  • Criamos os rios provenientes de células altas com umidade.
  • Criamos um modelo simples de temperatura.
  • Usamos a matriz do bioma para as células e depois a alteramos.

Nesta parte, suplementaremos o ciclo da água com rios e temperatura, além de atribuirmos biomas mais interessantes às células.

O tutorial foi criado usando o Unity 2017.3.0p3.


Calor e água animam o mapa.

Geração de rio


Os rios são uma conseqüência do ciclo da água. De fato, eles são formados por escoamentos que se rompem com a ajuda da erosão do canal. Isso implica que você pode adicionar rios com base no valor dos drenos de células. No entanto, isso não garante que obteremos algo que se assemelhe a rios reais. Quando começamos o rio, ele terá que fluir o mais longe possível, potencialmente através de muitas células. Isso não é consistente com a nossa simulação do ciclo da água, que processa as células em paralelo. Além disso, geralmente é necessário o controle do número de rios em um mapa.

Como os rios são muito diferentes, nós os geraremos separadamente. Utilizamos os resultados da simulação do ciclo da água para determinar a localização dos rios, mas os rios, por sua vez, não afetarão a simulação.

Por que o fluxo do rio às vezes está errado?
TriangulateWaterShore , . , . , , . , . , , . («»).

  void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … } 

Células de alta umidade


Nos nossos mapas, uma célula pode ou não ter um rio. Além disso, eles podem ramificar ou conectar-se. Na realidade, os rios são muito mais flexíveis, mas temos que conviver com essa aproximação, que cria apenas grandes rios. Mais importante, precisamos determinar a localização do início de um rio grande, que é escolhido aleatoriamente.

Como os rios precisam de água, a fonte do rio deve estar em uma célula com alta umidade. Mas isso não é suficiente. Os rios correm pelas encostas, então, idealmente, a fonte deve ter uma grande altura. Quanto maior a célula acima do nível da água, melhor candidata é para o papel da fonte do rio. Podemos visualizar isso como dados do mapa dividindo a altura da célula pela altura máxima. Para que o resultado seja obtido em relação ao nível da água, vamos subtraí-lo de ambas as alturas antes de dividir.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } } 



Umidade e altitude. Número grande do mapa 1208905299 com configurações padrão.

Os melhores candidatos são aquelas células que possuem alta umidade e alta altura. Podemos combinar esses critérios multiplicando-os. O resultado será o valor da aptidão ou do peso para as nascentes dos rios.

  float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); 


Pesos para as fontes dos rios.

Idealmente, usaríamos esses pesos para rejeitar a seleção aleatória da célula de origem. Embora possamos criar uma lista com os pesos corretos e escolher uma opção, essa é uma abordagem não trivial e atrasa o processo de geração. Uma classificação mais simples de significância dividida em quatro níveis será suficiente para nós. Os primeiros candidatos serão pesos com valores acima de 0,75. Bons candidatos têm pesos de 0,5. Os candidatos elegíveis são maiores que 0,25. Todas as outras células são descartadas. Vamos mostrar como fica graficamente.

  float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (data > 0.75f) { cell.SetMapData(1f); } else if (data > 0.5f) { cell.SetMapData(0.5f); } else if (data > 0.25f) { cell.SetMapData(0.25f); } // cell.SetMapData(data); 


Categorias de pesos das nascentes dos rios.

Com esse esquema de classificação, é provável que obtenhamos rios com fontes nas áreas mais altas e úmidas do mapa. No entanto, permanece a probabilidade de criação de rios em áreas relativamente secas ou baixas, o que aumenta a variabilidade.

Adicione um método CreateRiversque preencha uma lista de células com base nesses critérios. As células elegíveis são adicionadas a essa lista uma vez, as boas duas vezes e os principais candidatos quatro vezes. As células subaquáticas são sempre descartadas, portanto você não pode vê-las.

  void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (cell.IsUnderwater) { continue; } ClimateData data = climate[i]; float weight = data.moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (weight > 0.75f) { riverOrigins.Add(cell); riverOrigins.Add(cell); } if (weight > 0.5f) { riverOrigins.Add(cell); } if (weight > 0.25f) { riverOrigins.Add(cell); } } ListPool<HexCell>.Add(riverOrigins); } 

Esse método deve ser chamado depois CreateClimatepara que os dados de umidade estejam disponíveis para nós.

  public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … } 

Após concluir a classificação, você pode se livrar da visualização de seus dados no mapa.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … // float data = // moisture * (cell.Elevation - waterLevel) / // (elevationMaximum - waterLevel); // if (data > 0.6f) { // cell.SetMapData(1f); // } // else if (data > 0.4f) { // cell.SetMapData(0.5f); // } // else if (data > 0.2f) { // cell.SetMapData(0.25f); // } } } 

Pontos do rio


Quantos rios precisamos? Este parâmetro deve ser personalizável. Como o comprimento dos rios varia, será mais lógico controlá-lo com a ajuda de pontos fluviais, que determinam o número de células terrestres nas quais os rios devem estar contidos. Vamos expressá-los como uma porcentagem com um máximo de 20% e um valor padrão de 10%. Como a porcentagem de sushi, esse é um valor-alvo, não garantido. Como resultado, podemos ter poucos candidatos ou rios muito curtos para cobrir a quantidade necessária de terra. É por isso que a porcentagem máxima não deve ser muito grande.

  [Range(0, 20)] public int riverPercentage = 10; 


Slider percentual de rios.

Para determinar os pontos do rio, expressos como o número de células, precisamos lembrar em quantas células terrestres foram geradas CreateLand.

  int cellCount, landCells; … void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); landCells = landBudget; for (int guard = 0; guard < 10000; guard++) { … } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); landCells -= landBudget; } } 

Lá dentro, o CreateRiversnúmero de pontos do rio agora pode ser calculado da mesma maneira que fazemos CreateLand.

  void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); ListPool<HexCell>.Add(riverOrigins); } 

Além disso, continuaremos a retirar e remover células aleatórias da lista original, enquanto ainda temos pontos e células de origem. Em caso de conclusão do número de pontos, exibiremos um aviso no console.

  int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); } if (riverBudget > 0) { Debug.LogWarning("Failed to use up river budget."); } 

Além disso, adicionamos um método para criar rios diretamente. Como parâmetro, ele precisa de uma célula inicial e, após a conclusão, deve retornar o comprimento do rio. Começamos armazenando um método que retorna comprimento zero.

  int CreateRiver (HexCell origin) { int length = 0; return length; } 

Vamos chamar esse método no final do ciclo que acabamos de adicionar CreateRivers, usando para reduzir o número de pontos restantes. Garantimos que um novo rio seja criado apenas se a célula selecionada não tiver um rio fluindo através dele.

  while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } } 

Rios atuais


É lógico criar rios que correm para o mar ou outro corpo de água. Quando começamos a partir da fonte, obtemos imediatamente o comprimento 1. Depois disso, selecionamos um vizinho aleatório e aumentamos o comprimento. Continuamos a nos mover até chegarmos à cela subaquática.

  int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { HexDirection direction = (HexDirection)Random.Range(0, 6); cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 


Rios aleatórios.

Como resultado de uma abordagem tão ingênua, obtemos fragmentos dispersos aleatoriamente, principalmente devido à substituição de rios gerados anteriormente. Isso pode até levar a erros, porque não verificamos se o vizinho realmente existe. Precisamos verificar todas as direções no loop e garantir que haja um vizinho lá. Se for, adicionamos essa direção à lista de possíveis direções de fluxo, mas apenas se o rio ainda não fluir através desse vizinho. Em seguida, selecione um valor aleatório nesta lista.

  List<HexDirection> flowDirections = new List<HexDirection>(); … int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor || neighbor.HasRiver) { continue; } flowDirections.Add(d); } HexDirection direction = // (HexDirection)Random.Range(0, 6); flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 

Com essa nova abordagem, podemos ter zero direções de fluxo disponíveis. Quando isso acontece, o rio não pode mais fluir mais e deve terminar. Se neste momento o comprimento for 1, isso significa que não podemos vazar da célula original, ou seja, não pode haver rio. Nesse caso, o comprimento do rio é zero.

  flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } 


Rios preservados.

Atropelar


Agora salvamos os rios já criados, mas ainda podemos obter fragmentos isolados dos rios. Isso acontece porque enquanto ignoramos as alturas. Cada vez que forçávamos o rio a fluir a uma altura maior, HexCell.SetOutgoingRiverinterrompíamos essa tentativa, que provocava rupturas nos rios. Portanto, também precisamos pular as direções que causam o fluxo dos rios.

  if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d); 


Rios descendo.

Então, nos livramos de muitos fragmentos de rios, mas alguns ainda permanecem. A partir deste momento, livrar-se dos rios mais feios se torna uma questão de refinamento. Para começar, os rios preferem fluir o mais rápido possível. Eles não escolherão necessariamente o caminho mais curto possível, mas a probabilidade disso é grande. Para simular isso, adicionaremos as direções três vezes à lista.

  if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d); 

Evite curvas fechadas


Além de fluir para baixo, a água também tem inércia. É mais provável que um rio flua reto ou dobre um pouco do que fazer uma curva acentuada repentina. Podemos adicionar essa distorção rastreando a última direção do rio. Se a direção potencial da corrente não se desviar muito dessa direção, adicione-a à lista novamente. Isso não é um problema para a fonte; portanto, sempre a adicionamos novamente.

  int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; HexDirection direction = HexDirection.NE; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } if ( length == 1 || (d != direction.Next2() && d != direction.Previous2()) ) { flowDirections.Add(d); } flowDirections.Add(d); } if (flowDirections.Count == 0) { return length > 1 ? length : 0; } // HexDirection direction = direction = flowDirections[Random.Range(0, flowDirections.Count)]; cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; } 

Isso reduz muito a probabilidade de os ziguezagues dos rios parecerem feios.


Menos curvas fechadas.

Confluência do rio


Às vezes acontece que o rio flui próximo à fonte do rio criado anteriormente. Se a fonte desse rio não estiver em uma altitude mais alta, podemos decidir que o novo rio flui para o antigo. Como resultado, temos um rio longo, e não dois vizinhos.

Para fazer isso, deixaremos o vizinho passar apenas se houver um rio chegando ou se for a fonte do rio atual. Depois de determinar que essa direção não está voltada para cima, verificamos se há um rio de saída. Se houver, então encontramos novamente o rio antigo. Como isso acontece muito raramente, não nos empenharemos em verificar outras fontes vizinhas e combinaremos imediatamente os rios.

  HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor.HasRiver) { // continue; // } if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } if (neighbor.HasOutgoingRiver) { cell.SetOutgoingRiver(d); return length; } 



Rios antes e depois da piscina.

Manter distância


Como os bons candidatos ao papel de origem geralmente são agrupados, obteremos grupos de rios. Além disso, podemos ter rios que levam a fonte ao lado do reservatório, resultando em rios de comprimento 1. Podemos distribuir as fontes, descartando aquelas que estão nas proximidades do rio ou reservatório. Fazemos isso ignorando os vizinhos da fonte selecionada em um loop interno CreateRivers. Se encontrarmos um vizinho que viola as regras, a fonte não nos convém e devemos ignorá-lo.

  while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); if (!origin.HasRiver) { bool isValidOrigin = true; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = origin.GetNeighbor(d); if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) { isValidOrigin = false; break; } } if (isValidOrigin) { riverBudget -= CreateRiver(origin); } } 

E embora os rios ainda fluam um ao lado do outro, eles tendem a cobrir uma área maior.



Sem distância e com ela.

Terminamos o rio com um lago


Nem todos os rios chegam ao reservatório, alguns ficam presos nos vales ou são bloqueados por outros rios. Este não é um problema específico, porque geralmente os rios reais também parecem desaparecer. Isso pode acontecer, por exemplo, se eles fluem no subsolo, dispersam em uma área pantanosa ou secam. Nossos rios não podem visualizar isso, então eles simplesmente terminam.

No entanto, podemos tentar minimizar o número desses casos. Embora não possamos unir os rios ou fazê-los fluir, podemos fazê-los terminar em lagos, o que geralmente é encontrado na realidade e parece bom. Para issoCreateRiverdeve elevar o nível da água na célula se ficar preso. A possibilidade disso depende da altura mínima dos vizinhos desta célula. Portanto, para acompanhar isso ao estudar vizinhos, é necessária uma pequena alteração no código.

  while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); // if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) { // continue; // } if (!neighbor) { continue; } if (neighbor.Elevation < minNeighborElevation) { minNeighborElevation = neighbor.Elevation; } if (neighbor == origin || neighbor.HasIncomingRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } … } … } 

Se estivermos presos, primeiro precisamos verificar se ainda estamos na fonte. Se sim, basta cancelar o rio. Caso contrário, verificamos se todos os vizinhos são pelo menos tão altos quanto a célula atual. Nesse caso, podemos elevar a água para este nível. Isso criará um lago a partir de uma célula, a menos que a altura da célula permaneça no mesmo nível. Nesse caso, basta atribuir a altura um nível abaixo do nível da água.

  if (flowDirections.Count == 0) { // return length > 1 ? length : 0; if (length == 1) { return 0; } if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = minNeighborElevation; if (minNeighborElevation == cell.Elevation) { cell.Elevation = minNeighborElevation - 1; } } break; } 



As extremidades dos rios sem lagos e com lagos. Nesse caso, a porcentagem de rios é 20.

Observe que agora podemos ter células subaquáticas acima do nível da água usada para gerar o mapa. Eles irão indicar lagos acima do nível do mar.

Lagos adicionais


Também podemos criar lagos, mesmo que não fiquemos presos. Isso pode resultar em um rio entrando e saindo do lago. Se não estivermos presos, um lago poderá ser criado elevando o nível da água e a altura atual da célula e reduzindo a altura da célula. Isso se aplica somente quando a altura mínima do vizinho é pelo menos igual à altura da célula atual. Fazemos isso no final do ciclo do rio e antes de passar para a próxima célula.

  while (!cell.IsUnderwater) { … if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } cell = cell.GetNeighbor(direction); } 



Sem lagos adicionais e com eles.

Vários lagos são lindos, mas sem limites, podemos criar muitos lagos. Portanto, vamos adicionar uma probabilidade personalizada para lagos adicionais, com um valor padrão de 0,25.

  [Range(0f, 1f)] public float extraLakeProbability = 0.25f; 

Ela controlará a probabilidade de gerar um lago adicional, se possível.

  if ( minNeighborElevation >= cell.Elevation && Random.value < extraLakeProbability ) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } 



Lagos adicionais.

Que tal criar lagos com mais de uma célula?
, , , . . : . , . , , , .

unitypackage

Temperatura


A água é apenas um dos fatores que podem determinar o bioma de uma célula. Outro fator importante é a temperatura. Embora possamos simular o fluxo e a difusão de temperaturas, como a simulação da água, para criar um clima interessante, precisamos apenas de um fator complexo. Portanto, vamos manter a temperatura simples e configurá-la para cada célula.

Temperatura e latitude


A maior influência na temperatura é a latitude. Está quente no equador, frio nos pólos e há uma transição suave entre eles. Vamos criar um método DetermineTemperatureque retorne a temperatura de uma determinada célula. Para começar, basta usar a coordenada Z da célula dividida pela dimensão Z como latitude e, em seguida, usar esse valor como temperatura.

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; return latitude; } 

Definimos a temperatura SetTerrainTypee a usamos como dados do mapa.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell); cell.SetMapData(temperature); float moisture = climate[i].moisture; … } } 


Latitude como temperatura, hemisfério sul.

Temos um gradiente linear de temperatura aumentando de baixo para cima. Você pode usá-lo para simular o hemisfério sul, com um polo na parte inferior e um equador na parte superior. Mas não precisamos descrever o hemisfério inteiro. Com uma diferença de temperatura menor ou nenhuma diferença, podemos descrever uma área menor. Para fazer isso, tornaremos as temperaturas baixas e altas personalizáveis. Definiremos essas temperaturas no intervalo de 0 a 1 e usaremos os valores extremos como valores padrão.

  [Range(0f, 1f)] public float lowTemperature = 0f; [Range(0f, 1f)] public float highTemperature = 1f; 


Controle deslizante de temperatura.

Aplicamos a faixa de temperatura usando interpolação linear, usando a latitude como interpolador. Como expressamos latitude como um valor de 0 a 1, podemos usá-lo Mathf.LerpUnclamped.

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; } 

Observe que baixas temperaturas não são necessariamente mais baixas que altas. Se desejar, você pode entregá-los.

Hemisfério


Agora podemos simular o hemisfério sul e, possivelmente, o norte, se medirmos primeiro as temperaturas. Mas é muito mais conveniente usar uma opção de configuração separada para alternar entre hemisférios. Vamos criar uma enumeração e um campo para ele. Assim, também adicionaremos a opção de criar os dois hemisférios, que é aplicável por padrão.

  public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere; 


A escolha do hemisfério.

Se precisarmos do hemisfério norte, podemos simplesmente inverter a latitude, subtraindo-a de 1. Para simular os dois hemisférios, os pólos devem estar abaixo e acima do mapa, e o equador deve estar no meio. Você pode fazer isso duplicando a latitude, enquanto o hemisfério inferior será processado corretamente e o superior terá uma latitude de 1 a 2. Para corrigir isso, subtraímos a latitude de 2 quando exceder 1.

  float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; if (hemisphere == HemisphereMode.Both) { latitude *= 2f; if (latitude > 1f) { latitude = 2f - latitude; } } else if (hemisphere == HemisphereMode.North) { latitude = 1f - latitude; } float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; } 


Ambos os hemisférios.

Vale ressaltar que isso cria a possibilidade de criar um mapa exótico no qual o equador é frio e os pólos são quentes.

Quanto mais alto o frio


Além da latitude, a temperatura também é significativamente afetada pela altitude. Em média, quanto mais alto subimos, mais frio fica. Podemos transformar isso em um fator, como fizemos com os candidatos do rio. Nesse caso, usamos a altura da célula. Além disso, este indicador diminui com a altura, ou seja, igual a 1 menos a altura dividida pelo máximo em relação ao nível da água. Para que o indicador no nível mais alto não caia para zero, adicionamos ao divisor. Em seguida, use este indicador para dimensionar a temperatura.

  float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature; 


A altura afeta a temperatura.

Flutuações de temperatura


Podemos tornar a simplicidade do gradiente de temperatura menos perceptível adicionando flutuações aleatórias de temperatura. Uma pequena chance de torná-lo mais realista, mas com muita flutuação, eles parecerão arbitrários. Vamos personalizar o poder das flutuações de temperatura e expressá-lo como o desvio máximo de temperatura com um valor padrão de 0,1.

  [Range(0f, 1f)] public float temperatureJitter = 0.1f; 


Controle deslizante de flutuação de temperatura.

Tais flutuações devem ser suaves com pequenas alterações locais. Você pode usar nossa textura de ruído para isso. Vamos chamar HexMetrics.SampleNoisee usar como argumento a posição da célula, dimensionada em 0,1. Vamos pegar o canal W, centralizá-lo e escalá-lo pelo coeficiente de oscilação. Em seguida, adicionamos esse valor à temperatura calculada anteriormente.

  temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); temperature += (HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) * temperatureJitter; return temperature; 



Flutuações de temperatura com valores de 0,1 e 1.

Podemos adicionar uma ligeira variabilidade às flutuações em cada mapa, escolhendo aleatoriamente entre os quatro canais de ruído. Defina o canal uma vez SetTerrainTypee depois indexe os canais de cores DetermineTemperature.

  int temperatureJitterChannel; … void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { … } } float DetermineTemperature (HexCell cell) { … float jitter = HexMetrics.SampleNoise(cell.Position * 0.1f)[temperatureJitterChannel]; temperature += (jitter * 2f - 1f) * temperatureJitter; return temperature; } 


Diferentes flutuações de temperatura com força máxima.

unitypackage

Biomas


Agora que temos dados sobre umidade e temperatura, podemos criar uma matriz de bioma. Ao indexar essa matriz, podemos atribuir biomas a todas as células, criando um cenário mais complexo do que usar apenas uma dimensão de dados.

Matriz de bioma


Existem muitos modelos climáticos, mas não os usaremos. Vamos simplificar, estamos interessados ​​apenas na lógica. Seco significa deserto (frio ou quente), para isso usamos areia. Frio e molhado significa neve. Quente e úmido significa muita vegetação, ou seja, grama. Entre eles, teremos uma taiga ou tundra, que designaremos como uma textura acinzentada da terra. Uma matriz 4 × 4 será suficiente para criar transições entre esses biomas.

Anteriormente, atribuímos tipos de elevação com base em cinco intervalos de umidade. Simplesmente baixamos a faixa mais seca para 0,05 e salvamos o resto. Para faixas de temperatura, usamos 0,1, 0,3, 0,6 e superior. Por conveniência, definiremos esses valores em matrizes estáticas.

  static float[] temperatureBands = { 0.1f, 0.3f, 0.6f }; static float[] moistureBands = { 0.12f, 0.28f, 0.85f }; 

Embora tenhamos especificado apenas o tipo de relevo com base no bioma, podemos usá-lo para determinar outros parâmetros. Portanto, vamos definir em uma HexMapGeneratorestrutura Biomeque descreve a configuração de um bioma individual. Até o momento, ele contém apenas o índice de resposta mais o método construtor correspondente.

  struct Biome { public int terrain; public Biome (int terrain) { this.terrain = terrain; } } 

Usamos essa estrutura para criar uma matriz estática contendo dados da matriz. Usamos a umidade como coordenada X e a temperatura como Y. Enchemos a linha com a temperatura mais baixa com neve, a segunda linha com tundra e as outras duas com grama. Em seguida, substituímos a coluna mais seca pelo deserto, redefinindo a opção de temperatura.

  static Biome[] biomes = { new Biome(0), new Biome(4), new Biome(4), new Biome(4), new Biome(0), new Biome(2), new Biome(2), new Biome(2), new Biome(0), new Biome(1), new Biome(1), new Biome(1), new Biome(0), new Biome(1), new Biome(1), new Biome(1) }; 


Matriz de biomas com índices de uma matriz unidimensional.

Definição de bioma


Para determinar as SetTerrainTypecélulas no bioma, percorreremos as faixas de temperatura e umidade no ciclo para determinar os índices de matriz que precisamos. Nós os usamos para obter o bioma desejado e especificar o tipo de topografia celular.

  void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell); // cell.SetMapData(temperature); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { // if (moisture < 0.05f) { // cell.TerrainTypeIndex = 4; // } // … // else { // cell.TerrainTypeIndex = 2; // } int t = 0; for (; t < temperatureBands.Length; t++) { if (temperature < temperatureBands[t]) { break; } } int m = 0; for (; m < moistureBands.Length; m++) { if (moisture < moistureBands[m]) { break; } } Biome cellBiome = biomes[t * 4 + m]; cell.TerrainTypeIndex = cellBiome.terrain; } else { cell.TerrainTypeIndex = 2; } } } 


Alívio baseado em uma matriz de bioma.

Configuração do bioma


Podemos ir além dos biomas definidos na matriz. Por exemplo, na matriz, todos os biomas secos são definidos como desertos de areia, mas nem todos os desertos secos são preenchidos com areia. Existem muitos desertos rochosos que parecem muito diferentes. Portanto, vamos substituir algumas das células do deserto por pedras. Faremos isso simplesmente com base na altura: a areia está em baixas altitudes e as rochas nuas geralmente são encontradas acima.

Suponha que a areia vire pedra quando a altura da célula estiver mais próxima da altura máxima do que do nível da água. Esta é a linha de altura dos desertos rochosos que podemos calcular no início SetTerrainType. Quando encontramos uma célula com areia e sua altura é grande o suficiente, alteramos o relevo do bioma para pedra.

  void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); int rockDesertElevation = elevationMaximum - (elevationMaximum - waterLevel) / 2; for (int i = 0; i < cellCount; i++) { … if (!cell.IsUnderwater) { … Biome cellBiome = biomes[t * 4 + m]; if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } cell.TerrainTypeIndex = cellBiome.terrain; } else { cell.TerrainTypeIndex = 2; } } } 


Areia e desertos rochosos.

Outra mudança baseada na altura é forçar as células na altura máxima a se transformarem em picos de neve, independentemente da temperatura, apenas se não estiverem muito secas. Isso aumentará a probabilidade de picos de neve perto do equador quente e úmido.

  if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } else if (cell.Elevation == elevationMaximum) { cellBiome.terrain = 4; } 


Bonés de neve na altura máxima.

Plantas


Agora vamos fazer biomas determinar o nível de células vegetais. Para fazer isso, adicione ao Biomecampo de plantas e inclua-o no construtor.

  struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } } 

Nos biomas mais frios e secos, não haverá plantas. Em todos os outros aspectos, quanto mais quente e úmido o clima, mais plantas. A segunda coluna de umidade recebe apenas o primeiro nível de plantas para a linha mais quente, portanto [0, 0, 0, 1]. A terceira coluna aumenta os níveis em um, com exceção da neve, ou seja, [0, 1, 1, 2]. E a coluna mais úmida aumenta novamente, ou seja, [0, 2, 2, 3]. Altere a matriz biomesadicionando a configuração da planta a ela.

  static Biome[] biomes = { new Biome(0, 0), new Biome(4, 0), new Biome(4, 0), new Biome(4, 0), new Biome(0, 0), new Biome(2, 0), new Biome(2, 1), new Biome(2, 2), new Biome(0, 0), new Biome(1, 0), new Biome(1, 1), new Biome(1, 2), new Biome(0, 0), new Biome(1, 1), new Biome(1, 2), new Biome(1, 3) }; 


Matriz de biomas com níveis de plantas.

Agora podemos definir o nível de plantas para a célula.

  cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant; 


Biomas com plantas.

As plantas agora parecem diferentes?
, . (1, 2, 1) (0.75, 1, 0.75). (1.5, 3, 1.5) (2, 1.5, 2). — (2, 4.5, 2) (2.5, 3, 2.5).

, : (13, 114, 0).

Podemos mudar o nível de plantas para biomas. Primeiro, precisamos garantir que eles não apareçam no terreno nevado, que já poderíamos montar. Em segundo lugar, vamos aumentar o nível de plantas ao longo dos rios, se ainda não estiver no máximo.

  if (cellBiome.terrain == 4) { cellBiome.plant = 0; } else if (cellBiome.plant < 3 && cell.HasRiver) { cellBiome.plant += 1; } cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant; 


Plantas modificadas.

Biomas subaquáticos


Até aquele momento, ignorávamos completamente as células subaquáticas. Vamos adicionar uma pequena variação a eles, e não usaremos a textura da terra para todos eles. Uma solução simples com base na altura já será suficiente para criar uma imagem mais interessante. Por exemplo, vamos usar grama para células um passo abaixo do nível da água. Também vamos usar grama para células acima do nível da água, isto é, para lagos criados por rios. Células com uma altura negativa são áreas do fundo do mar, então usamos pedras para elas. Todas as outras células permanecem em terra.

  void SetTerrainType () { … if (!cell.IsUnderwater) { … } else { int terrain; if (cell.Elevation == waterLevel - 1) { terrain = 1; } else if (cell.Elevation >= waterLevel) { terrain = 1; } else if (cell.Elevation < 0) { terrain = 3; } else { terrain = 2; } cell.TerrainTypeIndex = terrain; } } } 


Variabilidade subaquática.

Vamos adicionar mais alguns detalhes para as células subaquáticas ao longo da costa. São células com pelo menos um vizinho acima da água. Se essa célula for rasa, criaremos uma praia. E se estiver próximo ao penhasco, será o detalhe visual dominante, e usaremos a pedra.

Para determinar isso, verificaremos os vizinhos das células localizadas um passo abaixo do nível da água. Vamos contar o número de conexões por falésias e encostas com células terrestres vizinhas.

  if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } int delta = neighbor.Elevation - cell.WaterLevel; if (delta == 0) { slopes += 1; } else if (delta > 0) { cliffs += 1; } } terrain = 1; } 

Agora podemos usar essas informações para classificar as células. Em primeiro lugar, se mais da metade dos vizinhos são terrenos, então estamos lidando com um lago ou uma baía. Para essas células, usamos uma textura de grama. Caso contrário, se tivermos penhascos, usaremos pedra. Caso contrário, se tivermos declives, usaremos areia para criar uma praia. A única opção restante é uma área rasa ao largo da costa, para a qual ainda usamos grama.

  if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { … } if (cliffs + slopes > 3) { terrain = 1; } else if (cliffs > 0) { terrain = 3; } else if (slopes > 0) { terrain = 0; } else { terrain = 1; } } 



Variabilidade da costa.

Como toque final, vamos verificar se não temos células subaquáticas verdes na faixa de temperatura mais baixa. Para essas células, usamos a terra.

  if (terrain == 1 && temperature < temperatureBands[0]) { terrain = 2; } cell.TerrainTypeIndex = terrain; 

Tivemos a oportunidade de gerar cartões aleatórios que parecem bastante interessantes e naturais, com muitas opções de configuração.

unitypackage

Parte 27: dobrando um cartão


  • Dividimos os cartões em colunas que podem ser movidas.
  • Centralize o cartão na câmera.
  • Colapsamos tudo.

Nesta última parte, adicionaremos suporte para minimizar o mapa, conectando as bordas leste e oeste.

O tutorial foi criado usando o Unity 2017.3.0p3.


Dobrar faz o mundo girar.

Cartões dobráveis


Nossos mapas podem ser usados ​​para modelar áreas de tamanhos diferentes, mas eles sempre estão limitados a uma forma retangular. Podemos criar um mapa de uma ilha ou de um continente inteiro, mas não de todo o planeta. Os planetas são esféricos, eles não têm limites rígidos que impedem o movimento em sua superfície. Se você continuar se movendo em uma direção, mais cedo ou mais tarde retornará ao ponto de partida.

Não podemos envolver uma grade de hexágonos em torno de uma esfera; essa sobreposição é impossível. Nas melhores aproximações, é utilizada a topologia icosaédrica, na qual as doze células devem ser pentágonos. No entanto, sem nenhuma distorção ou exceção, a malha pode ser enrolada ao redor do cilindro. Para fazer isso, basta conectar as bordas leste e oeste do mapa. Com exceção da lógica de empacotamento, tudo o resto permanece o mesmo.

Um cilindro é uma aproximação aproximada de uma esfera, porque não podemos modelar polos. Mas isso não impediu os desenvolvedores de muitos jogos de usar o leste para o oeste para modelar mapas do planeta. As regiões polares simplesmente não fazem parte da zona de jogo.

Que tal virar para o norte e sul?
, . , , . -, -. .

Existem duas maneiras de implementar a dobra cilíndrica. A primeira é tornar o mapa cilíndrico dobrando sua superfície e tudo o que está nele, de modo que as bordas leste e oeste estejam em contato. Agora você jogará não em uma superfície plana, mas em um cilindro real. A segunda abordagem é salvar um mapa plano e usar o teletransporte ou a duplicação para recolher. A maioria dos jogos usa a segunda abordagem, então vamos adotá-la.

Dobragem opcional


A necessidade de recolher o mapa depende de sua escala - local ou planetária. Podemos usar o suporte de ambos, tornando a dobragem opcional. Para fazer isso, adicione uma nova opção ao menu Criar novo mapa com o recolhimento ativado por padrão.


O menu do novo mapa com a opção de recolher.

Adicione ao NewMapMenucampo para rastrear a seleção, bem como um método para alterá-la. Vamos fazer com que esse método seja chamado quando o estado do comutador for alterado.

  bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; } 

Quando um novo mapa é solicitado, passamos o valor da opção minimizar.

  void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); } 

Altere-o HexMapGenerator.GenerateMappara que ele aceite esse novo argumento e depois o passe para HexGrid.CreateMap.

  public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … } 

código> O HexGrid deve saber se estamos recolhendo, portanto, adicione um campo a ele e CreateMapdefina-o. Outras classes devem mudar sua lógica, dependendo se a grade é minimizada, para tornar o campo geral. Além disso, permite definir o valor padrão por meio do inspetor.

  public int cellCountX = 20, cellCountZ = 15; public bool wrapping; … public bool CreateMap (int x, int z, bool wrapping) { … cellCountX = x; cellCountZ = z; this.wrapping = wrapping; … } 

HexGridchamadas próprias CreateMapem dois lugares. Podemos simplesmente usar seu próprio campo para o argumento de recolhimento.

  void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … } 


O interruptor de dobramento da grade está ativado por padrão.

Salvando e carregando


Como o dobramento está definido para cada cartão, ele deve ser salvo e carregado. Isso significa que você precisa alterar o formato de salvamento de arquivo, para aumentar a versão constantemente SaveLoadMenu.

  const int mapFileVersion = 5; 

Ao salvar, HexGridbasta escrever o valor da dobra booleana após o tamanho do mapa.

  public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … } 

Ao carregar, vamos lê-lo apenas com a versão correta do arquivo. Se for diferente, esse é um cartão antigo e não deve ser minimizado. Salve essas informações em uma variável local e compare-as com o estado atual da dobra. Se for diferente, não podemos reutilizar a topologia de mapa existente da mesma maneira que faria ao carregar um mapa com outros tamanhos.

  public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } bool wrapping = header >= 5 ? reader.ReadBoolean() : false; if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) { if (!CreateMap(x, z, wrapping)) { return; } } … } 

Métricas dobráveis


Minimizar o mapa exigirá grandes alterações na lógica, por exemplo, ao calcular distâncias. Portanto, eles podem tocar no código que não possui um link direto para a grade. Em vez de passar essas informações como argumentos, vamos adicioná-las HexMetrics. Adicione um número inteiro estático que contenha o tamanho da dobra que corresponda à largura do mapa. Se for maior que zero, estamos lidando com um cartão dobrável. Para verificar isso, adicione uma propriedade.

  public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } } 

Precisamos definir o tamanho da dobra para cada chamada HexGrid.CreateMap.

  public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … } 

Como esses dados não sobreviverão à recompilação no modo Play, nós os definiremos OnEnable.

  void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } } 

Largura da célula


Ao trabalhar com cartões dobráveis, geralmente precisamos lidar com as posições ao longo do eixo X, medidas na largura das células. Embora possa ser usado para isso HexMetrics.innerRadius * 2f, seria mais conveniente se não adicionássemos multiplicação sempre. Então, vamos adicionar uma constante HexMetrics.innerDiameter.

  public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f; 

Já podemos usar o diâmetro em três locais. Em primeiro lugar, HexGrid.CreateCellao posicionar uma nova célula.

  void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … } 

Em segundo lugar, HexMapCameralimitando a posição da câmera.

  Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … } 

E também na HexCoordinatesconversão de posição em coordenadas.

  public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … } 

unitypackage

Centralização do cartão


Quando o mapa não entra em colapso, ele define claramente as bordas leste e oeste e, portanto, um claro centro horizontal. Mas no caso de um cartão dobrável, tudo é diferente. Não tem a borda oriental nem a ocidental, nem o centro. Como alternativa, podemos assumir que o centro é onde está a câmera. Isso será útil porque queremos que o mapa esteja sempre centralizado em nosso ponto de vista. Então, onde quer que estejamos, não veremos as bordas leste ou oeste do mapa.

Colunas do fragmento do mapa


Para que a visualização do mapa seja centralizada em relação à câmera, precisamos alterar o posicionamento dos elementos, dependendo do movimento da câmera. Se ele se move para oeste, precisamos pegar o que está atualmente na borda da parte leste e movê-lo para a borda da parte ocidental. O mesmo se aplica à direção oposta.

Idealmente, assim que a câmera se mover para a coluna vizinha de células, devemos mover imediatamente a coluna mais distante para o outro lado. No entanto, não precisamos ser tão precisos. Em vez disso, podemos transferir fragmentos inteiros do mapa. Isso nos permite mover partes do mapa sem precisar modificar as malhas.

Como estamos movendo colunas inteiras de fragmentos ao mesmo tempo, vamos agrupá-los criando um objeto de coluna pai para cada grupo. Adicione uma matriz para esses objetos HexGride nós a inicializaremos CreateChunks. Nós os usaremos apenas como contêineres, portanto, precisamos rastrear o link para seus componentes Transform. Como no caso de fragmentos, suas posições iniciais estão localizadas na origem local das coordenadas da grade.

  Transform[] columns; … void CreateChunks () { columns = new Transform[chunkCountX]; for (int x = 0; x < chunkCountX; x++) { columns[x] = new GameObject("Column").transform; columns[x].SetParent(transform, false); } … } 

Agora, o fragmento deve se tornar filho da coluna correspondente, não da grade.

  void CreateChunks () { … chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(columns[x], false); } } } 


Fragmentos agrupados em colunas.

Como agora todos os fragmentos se tornaram filhos das colunas, CreateMapbasta destruirmos diretamente todas as colunas, não os fragmentos. Então, vamos nos livrar dos fragmentos da filha.

  public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … } 

Colunas de teletransporte


Adicione ao HexGridnovo método CenterMapa posição X como parâmetro. Converta a posição no índice da coluna, dividindo-a pela largura do fragmento em unidades do Unity. Este será o índice da coluna na qual a câmera está localizada atualmente, ou seja, será a coluna central do mapa.

  public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); } 

Basta mudar a visualização do mapa apenas quando o índice da coluna central é alterado. Então, vamos acompanhar em campo. Usamos o valor padrão -1 ao criar um mapa, para que novos mapas estejam sempre centralizados.

  int currentCenterColumnIndex = -1; … public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; currentCenterColumnIndex = -1; … } … public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); if (centerColumnIndex == currentCenterColumnIndex) { return; } currentCenterColumnIndex = centerColumnIndex; } 

Agora que conhecemos o índice da coluna central, podemos determinar os índices mínimo e máximo, subtraindo e adicionando metade do número de colunas. Como usamos valores inteiros, com um número ímpar de colunas, isso funciona perfeitamente. No caso de um número par, não pode haver uma coluna perfeitamente centralizada; portanto, um dos índices estará um passo além do necessário. Isso cria um deslocamento de uma coluna na direção da extremidade mais distante do mapa, mas para nós isso não é um problema.

  currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; 

Observe que esses índices podem ser negativos ou maiores que o índice máximo natural da coluna. O mínimo é zero apenas quando a câmera está perto do centro natural do mapa. Nossa tarefa é mover as colunas para que correspondam a esses índices relativos. Isso pode ser feito alterando a coordenada X local de cada coluna no loop.

  int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; Vector3 position; position.y = position.z = 0f; for (int i = 0; i < columns.Length; i++) { position.x = 0f; columns[i].localPosition = position; } 

Para cada coluna, verificamos se o índice do índice mínimo é menor. Nesse caso, está muito longe à esquerda do centro. Ele deve se teletransportar para o outro lado do mapa. Isso pode ser feito tornando sua coordenada X igual à largura do mapa. Da mesma forma, se o índice da coluna for maior que o índice máximo, ele estará muito distante à direita do centro e deverá se teletransportar para o outro lado.

  for (int i = 0; i < columns.Length; i++) { if (i < minColumnIndex) { position.x = chunkCountX * (HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else if (i > maxColumnIndex) { position.x = chunkCountX * -(HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else { position.x = 0f; } columns[i].localPosition = position; } 

Movimento da câmera


Mude HexMapCamera.AdjustPositionpara que, ao trabalhar com um cartão dobrável, ele ClampPositionligue WrapPosition. Primeiro, basta tornar o novo método uma WrapPositionduplicata ClampPosition, mas com a única diferença: no final, ele chamará CenterMap.

  void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = grid.wrapping ? WrapPosition(position) : ClampPosition(position); } … Vector3 WrapPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; } 

Para mapa foi imediatamente centrado, chamar OnEnablemétodo ValidatePosition.

  void OnEnable () { instance = this; ValidatePosition(); } 


Mova para a esquerda e direita ao centralizar na câmera.

Embora ainda restringamos o movimento da câmera, o mapa agora tenta centralizar em relação à câmera, teleportando colunas de fragmentos do mapa, se necessário. Com um pequeno mapa e uma câmera remota, isso é claramente visível, mas em um mapa grande, fragmentos teletransportados estão fora do alcance de visualização da câmera. Obviamente, apenas as bordas leste e oeste do mapa são perceptíveis, porque ainda não há triangulação entre elas.

, Remover a restrição da sua coordenada X, a fim de minimizar e de câmara WrapPosition. Em vez disso, continuaremos aumentando a coordenada X pela largura do mapa, enquanto estiver abaixo de zero, e reduzindo-a enquanto for maior que a largura do mapa.

  Vector3 WrapPosition (Vector3 position) { // float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; // position.x = Mathf.Clamp(position.x, 0f, xMax); float width = grid.cellCountX * HexMetrics.innerDiameter; while (position.x < 0f) { position.x += width; } while (position.x > width) { position.x -= width; } float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; } 


A câmera de rolagem se move ao longo do mapa.

Texturas de Shader dobráveis


Com exceção do espaço de triangulação, minimizar a câmera no modo de jogo deve ser imperceptível. No entanto, quando isso acontece, uma alteração visual ocorre na metade da topografia e na água. Isso acontece porque usamos uma posição no mundo para provar essas texturas. Um teletransporte nítido do fragmento altera a localização das texturas.

Podemos resolver esse problema fazendo as texturas aparecerem em blocos que são múltiplos do tamanho do fragmento. O tamanho do fragmento é calculado a partir das constantes HexMetrics, portanto, vamos criar o arquivo de inclusão do sombreador HexMetrics.cginc e colar as definições correspondentes nele. A escala básica de ladrilhos é calculada a partir do tamanho do fragmento e do raio externo da célula. Se você usar outras métricas, precisará modificar o arquivo de acordo.

 #define OUTER_TO_INNER 0.866025404 #define OUTER_RADIUS 10 #define CHUNK_SIZE_X 5 #define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER)) 

Isso nos dá uma escala de lado a lado de 0,00866025404. Se usarmos um múltiplo inteiro desse valor, a texturização não será afetada pelo teletransporte de fragmentos. Além disso, as texturas nas bordas leste e oeste do mapa se unirão perfeitamente depois que triangularmos corretamente sua conexão. Usamos 0,02

como a escala UV no shader Terrain . Em vez disso, podemos usar a escala de mosaico dobrado, que é 0,01732050808. A escala é obtida um pouco menos do que era e a escala da textura aumentou um pouco, mas visualmente é invisível.

  #include "../HexMetrics.cginc" #include "../HexCellData.cginc" … float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3( IN.worldPos.xz * (2 * TILING_SCALE), IN.terrain[index] ); … } 

No shader Roads para ruído UV, usamos uma escala de 0,025. Em vez disso, você pode usar a escala de mosaico triplo. Isso nos dá 0,02598076212, que é bem próximo.

  #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … } 

Finalmente, em Water.cginc usamos 0,015 para espuma e 0,025 para ondas. Aqui, podemos novamente substituir esses valores por uma escala de ladrilhos dobrada e triplicada.

 #include "HexMetrics.cginc" float Foam (float shore, float2 worldXZ, sampler2D noiseTex) { shore = sqrt(shore) * 0.9; float2 noiseUV = worldXZ + _Time.y * 0.25; float4 noise = tex2D(noiseTex, noiseUV * (2 * TILING_SCALE)); … } … float Waves (float2 worldXZ, sampler2D noiseTex) { float2 uv1 = worldXZ; uv1.y += _Time.y; float4 noise1 = tex2D(noiseTex, uv1 * (3 * TILING_SCALE)); float2 uv2 = worldXZ; uv2.x += _Time.y; float4 noise2 = tex2D(noiseTex, uv2 * (3 * TILING_SCALE)); … } 

unitypackage

A união do leste e oeste


Nesse estágio, a única evidência visual de minimizar o mapa é um pequeno espaço entre as colunas mais a leste e mais a oeste. Essa lacuna ocorre porque ainda não triangulamos as conexões de arestas e ângulos entre células em lados opostos do mapa sem dobrar.


Espaço na borda.

Vizinhos dobráveis


Para triangular a conexão leste-oeste, precisamos tornar as células dos lados opostos próximas umas das outras. Até o momento, não estamos fazendo isso, porque a HexGrid.CreateCellconexão E - W é estabelecida com a célula anterior apenas se seu índice em X for maior que zero. Para recolher essa conexão, precisamos conectar a última célula da linha com a primeira na mesma linha ao dobrar o mapa.

  void CreateCell (int x, int z, int i) { … if (x > 0) { cell.SetNeighbor(HexDirection.W, cells[i - 1]); if (wrapping && x == cellCountX - 1) { cell.SetNeighbor(HexDirection.E, cells[i - x]); } } … } 

Tendo estabelecido a conexão dos vizinhos E - W, obtemos uma triangulação parcial da lacuna. A conexão das arestas não é ideal, porque a distorção está oculta incorretamente. Nós vamos lidar com isso mais tarde.


Compostos E - W.

Também precisamos recolher os links NE - SW. Isso pode ser feito conectando a primeira célula de cada linha par com as últimas células da linha anterior. Será apenas a célula anterior.

  if (z > 0) { if ((z & 1) == 0) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]); if (x > 0) { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]); } else if (wrapping) { cell.SetNeighbor(HexDirection.SW, cells[i - 1]); } } else { … } } 


Conexões NE - SW.

Finalmente, as conexões SE - NW são estabelecidas no final de cada linha ímpar abaixo da primeira. Essas células devem estar conectadas à primeira célula da linha anterior.

  if (z > 0) { if ((z & 1) == 0) { … } else { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]); if (x < cellCountX - 1) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]); } else if (wrapping) { cell.SetNeighbor( HexDirection.SE, cells[i - cellCountX * 2 + 1] ); } } } 


Compostos SE - NW.

Dobragem de ruído


Para ocultar perfeitamente a lacuna, precisamos garantir que as bordas leste e oeste do mapa correspondam ao ruído perfeitamente usado para distorcer as posições dos vértices. Podemos usar o mesmo truque usado para shaders, mas uma escala de ruído de 0,003 foi usada para distorção. Para garantir a telha, é necessário aumentar significativamente a escala, o que levará a uma distorção mais caótica dos vértices.

Uma solução alternativa não é reduzir o ruído, mas sim atenuar o ruído nas bordas do mapa. Se você realizar uma atenuação suave ao longo da largura de uma célula, a distorção criará uma transição suave sem intervalos. O ruído nessa área será suavizado e, a uma longa distância, a alteração parecerá nítida, mas isso não é tão óbvio ao usar uma leve distorção dos vértices.

E as flutuações de temperatura?
. , . , . , .

Se não recolhermos o cartão, podemos conviver com uma HexMetrics.SampleNoiseúnica amostra. Mas ao dobrar, é necessário adicionar atenuação. Portanto, antes de retornar a amostra, salve-a em uma variável.

  public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; } 

Ao minimizar, precisamos misturar com a segunda amostra. Como realizaremos a transição na parte leste do mapa, a segunda amostra precisa ser movida para o oeste.

  Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); } 

A atenuação é realizada usando interpolação linear simples da parte oeste para a parte leste, ao longo da largura de uma célula.

  if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) ); } 


Mistura de ruído, uma solução imperfeita.

Como resultado, não obtemos uma correspondência exata, porque algumas das células no lado leste têm coordenadas X negativas. Para não se aproximar dessa área, vamos mover a região de transição para o oeste, metade da largura da célula.

  if (Wrapping && position.x < innerDiameter * 1.5f) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) - 0.5f ); } 


Atenuação correta.

Edição de Células


Agora que a triangulação parece correta, vamos ter certeza de que podemos editar tudo no mapa e na costura da dobra. Como se vê, em fragmentos teletransportados, as coordenadas são errôneas e os pincéis grandes são cortados por uma costura.


O pincel é aparado.

Para corrigir isso, precisamos relatar a HexCoordinatesdobra. Podemos fazer isso combinando a coordenada X no método construtor. Sabemos que a coordenada axial X é obtida a partir da coordenada X do deslocamento, subtraindo metade da coordenada Z. Você pode usar essas informações para realizar a transformação inversa e verificar se a coordenada zero é menor que zero. Nesse caso, temos a coordenada além do lado leste do mapa desdobrado. Como em cada direção nós teleportamos não mais do que a metade do mapa, será suficiente adicionarmos o tamanho da dobra a X uma vez. E quando a coordenada de deslocamento é maior que o tamanho da dobra, precisamos realizar uma subtração.

  public HexCoordinates (int x, int z) { if (HexMetrics.Wrapping) { int oX = x + z / 2; if (oX < 0) { x += HexMetrics.wrapSize; } else if (oX >= HexMetrics.wrapSize) { x -= HexMetrics.wrapSize; } } this.x = x; this.z = z; } 

Às vezes, ao editar a parte inferior ou superior do mapa, recebo erros
Isso acontece quando, devido à distorção dos vértices, o cursor aparece na linha de células fora do mapa. Este é um erro que ocorre porque não combinamos as coordenadas HexGrid.GetCellcom o parâmetro vetorial. Isso pode ser corrigido aplicando um método GetCellcom coordenadas como parâmetros que realizarão as verificações necessárias.

  public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position); // int index = // coordinates.X + coordinates.Z * cellCountX + coordinates.Z / 2; // return cells[index]; return GetCell(coordinates); } 

Dobragem costeira


A triangulação funciona bem para o terreno, mas ao longo da costura leste-oeste não existem bordas da costa da água. Na verdade, eles são, eles simplesmente não entram em colapso. Eles são virados e esticados para o outro lado do mapa.


Falta a borda da água.

Isso acontece porque, ao triangular a água da costa, usamos a posição de um vizinho. Para consertar isso, precisamos determinar com o que estamos lidando, localizado no outro lado do cartão. Para simplificar a tarefa, adicionaremos uma HexCellcoluna de célula à propriedade do índice.

  public int ColumnIndex { get; set; } 

Atribua este índice a HexGrid.CreateCell. É simplesmente igual à coordenada de deslocamento X dividida pelo tamanho do fragmento.

  void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … } 

Agora podemos HexGridChunk.TriangulateWaterShoredeterminar o que é minimizado comparando o índice da coluna da célula atual e seu vizinho. Se o índice da coluna do vizinho for menor que um passo a menos, estaremos no lado oeste e o vizinho no lado leste. Portanto, precisamos virar nosso vizinho para o oeste. O mesmo e na direção oposta.

  Vector3 center2 = neighbor.Position; if (neighbor.ColumnIndex < cell.ColumnIndex - 1) { center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) { center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } 


Costelas da costa, mas sem cantos.

Por isso, cuidamos das costelas da costa, mas até agora não lidávamos com cantos. Precisamos fazer o mesmo com o próximo vizinho.

  if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) { center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) { center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); … } 


Costa adequadamente reduzida.

Geração de cartão


A opção de conectar os lados leste e oeste afeta a geração de mapas. Ao minimizar o mapa, o algoritmo de geração também deve ser minimizado. Isso levará à criação de outro mapa, mas ao usar uma borda de mapa diferente de zero X, a dobra não é óbvia.



Mapa grande 1208905299 com configurações padrão. Com dobrável e sem ele.

Quando minimizado não faz sentido usar o Map Border o X . Mas não podemos simplesmente nos livrar disso, porque ao mesmo tempo as regiões se fundem. Ao minimizar, podemos usar apenas um RegionBorder .

Nós mudamos HexMapGenerator.CreateRegions, substituindo em todos os casos mapBorderXpor borderX. Essa nova variável será igual a ou regionBorder, ou mapBorderX, dependendo do valor da opção de recolhimento. Abaixo, mostrei as alterações apenas no primeiro caso.

  int borderX = grid.wrapping ? regionBorder : mapBorderX; MapRegion region; switch (regionCount) { default: region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; … } 

Ao mesmo tempo, as regiões permanecem separadas, mas isso é necessário apenas se houver regiões diferentes nos lados leste e oeste do mapa. Existem dois casos em que isso não é respeitado. A primeira é quando temos apenas uma região. A segunda é quando há duas regiões dividindo o mapa horizontalmente. Nesses casos, podemos atribuir um borderXvalor zero, o que permitirá que as massas de terra atravessem a costura leste-oeste.

  switch (regionCount) { default: if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { … } else { if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; … } 


Uma região está entrando em colapso.

À primeira vista, parece que tudo funciona corretamente, mas há realmente uma lacuna ao longo da costura. Isso se torna mais perceptível se você definir a Porcentagem de erosão para zero.



Quando a erosão é desativada, uma costura no relevo se torna perceptível.

A diferença ocorre porque a costura impede o crescimento de fragmentos de alívio. Para determinar o que é adicionado primeiro, é usada a distância da célula ao centro do fragmento, e as células do outro lado do mapa podem estar muito distantes, portanto quase nunca são ativadas. Claro, isso está errado. Precisamos ter certeza de que HexCoordinates.DistanceToconhecemos o mapa minimizado.

Calculamos a distância entre HexCoordinates, somando as distâncias absolutas ao longo de cada um dos três eixos e reduzindo pela metade o resultado. A distância ao longo de Z é sempre verdadeira, mas dobrar ao longo pode afetar as distâncias X e Y. Então, vamos começar com um cálculo separado de X + Y.

  public int DistanceTo (HexCoordinates other) { // return // ((x < other.x ? other.x - x : x - other.x) + // (Y < other.Y ? other.Y - Y : Y - other.Y) + // (z < other.z ? other.z - z : z - other.z)) / 2; int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); return (xy + (z < other.z ? other.z - z : z - other.z)) / 2; } 

Determinar se a dobra cria uma distância menor para células arbitrárias não é uma tarefa fácil, então vamos calcular X + Y para os casos em que estamos dobrando outra coordenada para o lado oeste. Se o valor for menor que o X + Y original, use-o.

  int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } 

Se isso não levar a uma distância menor, é possível diminuir a velocidade na outra direção, para que possamos verificar.

  if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } else { other.x -= 2 * HexMetrics.wrapSize; xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } } 

Agora sempre obtemos a menor distância no mapa dobrável. Fragmentos de terreno não são mais bloqueados por uma costura, o que permite que as massas de terra se enrolem.



Alívio corretamente dobrável, sem erosão e erosão.

unitypackage

Viajando pelo mundo


Depois de considerar a geração e triangulação de mapas, passemos agora a verificar esquadrões, exploração e visibilidade.

Costura de teste


O primeiro obstáculo que encontramos ao mover um esquadrão pelo mundo é a borda do mapa, que não pode ser explorada.


A costura do cartão não pode ser examinada.

As células ao longo da borda do mapa são inexploradas para ocultar a conclusão abrupta do mapa. Mas quando o mapa é minimizado, apenas as células norte e sul devem ser marcadas, mas não o leste e o oeste. Mude HexGrid.CreateCellpara levar isso em consideração.

  if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; } 

Visibilidade dos recursos de relevo


Agora vamos verificar se a visibilidade funciona ao longo da costura. Funciona para terrenos, mas não para objetos de terrenos. Parece que objetos em colapso obtêm a visibilidade da última célula que não foi recolhida.


Visibilidade incorreta dos objetos.

Isso acontece porque o modo de HexCellShaderDatafixação é definido para o modo de dobra de textura usado . Para resolver o problema, basta alterar o modo de fixação para repetir. Mas precisamos fazer isso apenas para as coordenadas de U, portanto Initialize, definiremos wrapModeUisso wrapModeVseparadamente.

  public void Initialize (int x, int z) { if (cellTexture) { cellTexture.Resize(x, z); } else { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point; // cellTexture.wrapMode = TextureWrapMode.Clamp; cellTexture.wrapModeU = TextureWrapMode.Repeat; cellTexture.wrapModeV = TextureWrapMode.Clamp; Shader.SetGlobalTexture("_HexCellData", cellTexture); } … } 

Esquadrões e Colunas


Outro problema é que as unidades ainda não estão desmoronando. Depois de mover a coluna em que estão localizadas, as unidades permanecem no mesmo local.


A unidade não é transferida e está do lado errado.

Esse problema pode ser resolvido transformando os esquadrões em elementos filhos de colunas, como fizemos com os fragmentos. Primeiro, não os tornaremos os filhos imediatos da rede HexGrid.AddUnit.

  public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this; // unit.transform.SetParent(transform, false); unit.Location = location; unit.Orientation = orientation; } 

Como as unidades estão se movendo, elas podem aparecer em outra coluna, ou seja, será necessário alterar seus pais. Para tornar isso possível, adicionamos ao HexGridmétodo geral MakeChildOfColumne, como parâmetros, passamos a ele o componente do Transformelemento filho e o índice da coluna.

  public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); } 

Vamos chamar esse método quando a propriedade estiver configurada HexUnit.Location.

  public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } } 

Isso resolve o problema de criar unidades. Mas também precisamos fazê-los mover para a coluna desejada ao se mover. Para fazer isso, você precisa acompanhar HexUnit.TravelPatha coluna atual no índice. No início desse método, este é o índice da coluna da célula no início do caminho, ou o atual se a movimentação foi interrompida pela recompilação.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position); // Grid.DecreaseVisibility( // currentTravelLocation ? currentTravelLocation : pathToTravel[0], // VisionRange // ); if (!currentTravelLocation) { currentTravelLocation = pathToTravel[0]; } Grid.DecreaseVisibility(currentTravelLocation, VisionRange); int currentColumn = currentTravelLocation.ColumnIndex; … } 

Durante cada iteração da movimentação, verificaremos se o índice da próxima coluna é diferente e, se for, mudaremos o pai da ordem.

  int currentColumn = currentTravelLocation.ColumnIndex; float t = Time.deltaTime * travelSpeed; for (int i = 1; i < pathToTravel.Count; i++) { … Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } … } 

Isso permitirá que as unidades se movam de maneira semelhante aos fragmentos. No entanto, ao se mover pela costura do cartão, as unidades ainda não entram em colapso. Em vez disso, eles de repente começam a se mover na direção errada. Isso acontece independentemente da localização da costura, mas mais notavelmente quando eles pulam o mapa inteiro.


Corridas de cavalos pelo mapa.

Aqui podemos usar a mesma abordagem usada para a costa, só que desta vez faremos a curva pela qual o desapego se move. Se a próxima coluna for virada para leste, teleportaremos a curva também para leste, da mesma forma para a outra direção. Você precisa alterar os pontos de controle da curva ae b, o que também afetará o ponto de controle c.

  for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position; // c = (b + currentTravelLocation.Position) * 0.5f; // Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { if (nextColumn < currentColumn - 1) { ax -= HexMetrics.innerDiameter * HexMetrics.wrapSize; bx -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (nextColumn > currentColumn + 1) { ax += HexMetrics.innerDiameter * HexMetrics.wrapSize; bx += HexMetrics.innerDiameter * HexMetrics.wrapSize; } Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } c = (b + currentTravelLocation.Position) * 0.5f; Grid.IncreaseVisibility(pathToTravel[i], VisionRange); … } 


Movimento com dobradura.

A última coisa a fazer é mudar o turno inicial do esquadrão quando ele olha para a primeira célula na qual se moverá. Se esta célula estiver do outro lado da costura leste-oeste, a unidade olhará na direção errada.

Ao minimizar um mapa, há duas maneiras de observar um ponto que não está exatamente no norte ou no sul. Você pode olhar para o leste ou oeste. Será lógico olhar na direção correspondente à distância mais próxima do ponto, porque também é a direção do movimento, então vamos usá-lo LookAt.

Ao minimizar, verificaremos a distância relativa ao longo do eixo X. Se for menor que a metade negativa da largura do mapa, devemos olhar para o oeste, o que pode ser feito girando o ponto para o oeste. Caso contrário, se a distância for mais da metade da largura do mapa, devemos entrar em colapso para o leste.

  IEnumerator LookAt (Vector3 point) { if (HexMetrics.Wrapping) { float xDistance = point.x - transform.localPosition.x; if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } } … } 

Portanto, temos um mapa minimizado totalmente funcional. E isso conclui a série de tutoriais em mapas hexagonais. Como mencionado nas seções anteriores, outros tópicos podem ser considerados, mas não são específicos para mapas hexagonais. Talvez eu os considere em futuras séries de tutoriais.

Baixei o último pacote e recebo erros de turnos no modo Play
, Rotation . . . 5.

Baixei o último pacote e os gráficos não são tão bonitos quanto nas capturas de tela
. - .

Baixei o último pacote e ele gera constantemente o mesmo cartão
seed (1208905299), . , Use Fixed Seed .

unitypackage

Source: https://habr.com/ru/post/pt427567/


All Articles