Mapas hexagonais em Unity: neblina da guerra, pesquisa de mapas, geração de procedimentos

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 20: o nevoeiro da guerra


  • Salve os dados da célula na textura.
  • Altere os tipos de relevo sem triangulação.
  • Nós rastreamos a visibilidade.
  • Escureça tudo invisível.

Nesta parte, adicionaremos o efeito fog of war ao mapa.

Agora a série será criada no Unity 2017.1.0.


Agora vemos que podemos e não podemos ver.

Dados da célula no sombreador


Muitos jogos de estratégia usam o conceito de nevoeiro de guerra. Isso significa que a visão do jogador é limitada. Ele só pode ver o que está perto de suas unidades ou área controlada. Embora possamos ver o alívio, não sabemos o que está acontecendo lá. Normalmente, o terreno invisível fica mais escuro. Para perceber isso, precisamos rastrear a visibilidade da célula e renderizá-la de acordo.

A maneira mais simples de alterar a aparência das células ocultas é adicionar uma métrica de visibilidade aos dados da malha. No entanto, ao mesmo tempo, teremos que iniciar uma nova triangulação de relevo com uma alteração na visibilidade. Esta é uma péssima decisão, porque a visibilidade muda constantemente durante o jogo.

A técnica de renderização sobre a topografia de uma superfície translúcida é frequentemente usada, que mascara parcialmente as células invisíveis para o reprodutor. Este método é adequado para terrenos relativamente planos em combinação com um ângulo de visão limitado. Porém, como nosso terreno pode conter alturas e objetos muito variados que podem ser vistos de diferentes ângulos, para isso precisamos de uma malha altamente detalhada que corresponda à forma do terreno. Este método será mais caro que a abordagem mais simples mencionada acima.

Outra abordagem é transferir os dados das células para o shader ao renderizar separadamente da malha de alívio. Isso nos permitirá realizar a triangulação apenas uma vez. Os dados da célula podem ser transferidos usando textura. Alterar a textura é um processo muito mais simples do que triangular o terreno. Além disso, executar várias amostras de textura adicionais é mais rápido do que renderizar uma única camada translúcida.

Que tal usar matrizes de sombreador?
Você também pode transferir dados da célula para o shader usando uma matriz de vetores. No entanto, as matrizes de sombreador têm um limite de tamanho, medido em milhares de bytes, e as texturas podem conter milhões de pixels. Para suportar mapas grandes, usaremos texturas.

Cell Data Management


Precisamos de uma maneira de controlar a textura que contém os dados da célula. Vamos criar um novo componente HexCellShaderData que fará isso.

 using UnityEngine; public class HexCellShaderData : MonoBehaviour { Texture2D cellTexture; } 

Ao criar ou carregar um novo mapa, precisamos criar uma nova textura com o tamanho correto. Portanto, adicionamos um método de inicialização que cria uma textura para ele. Usamos uma textura RGBA sem texturas mip e espaço de cores linear. Como não precisamos misturar dados da célula, usamos a filtragem de pontos. Além disso, os dados não devem ser recolhidos. Cada pixel na textura conterá dados de uma célula.

  public void Initialize (int x, int z) { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point; cellTexture.wrapMode = TextureWrapMode.Clamp; } 

O tamanho da textura deve corresponder ao tamanho do mapa?
Não, ele só precisa ter pixels suficientes para armazenar todas as células. Com a correspondência exata com o tamanho do mapa, uma textura com tamanhos que não são potências de dois (NPOT sem potência de dois) provavelmente será criada e esse formato de textura não será o mais eficaz. Embora possamos configurar o código para trabalhar com texturas do tamanho de uma potência de dois, essa é uma otimização menor, o que complica o acesso aos dados da célula.

Na verdade, não precisamos criar uma nova textura toda vez que criamos um novo mapa. É suficiente redimensionar a textura, se ela já existir. Nem precisamos verificar se já temos o tamanho certo, porque o Texture2D.Resize é inteligente o suficiente para fazer isso por nós.

  public void Initialize (int x, int z) { if (cellTexture) { cellTexture.Resize(x, z); } else { cellTexture = new Texture2D( cellCountX, cellCountZ, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point; cellTexture.wrapMode = TextureWrapMode.Clamp; } } 

Em vez de aplicar os dados das células um pixel por vez, usamos um buffer de cores e aplicamos os dados de todas as células por vez. Para fazer isso, usaremos a matriz Color32 . Se necessário, criaremos uma nova instância de matriz no final de Initialize . Se já temos uma matriz do tamanho correto. então limpamos seu conteúdo.

  Texture2D cellTexture; Color32[] cellTextureData; public void Initialize () { … if (cellTextureData == null || cellTextureData.Length != x * z) { cellTextureData = new Color32[x * z]; } else { for (int i = 0; i < cellTextureData.Length; i++) { cellTextureData[i] = new Color32(0, 0, 0, 0); } } } 

O que é color32?
As texturas RGBA não compactadas padrão contêm pixels de quatro bytes. Cada um dos quatro canais de cores recebe um byte, ou seja, eles têm 256 valores possíveis. Ao usar a estrutura Unity Color , seus componentes de ponto flutuante no intervalo de 0 a 1 são convertidos em bytes no intervalo de 0 a 255. Na amostragem, a GPU realiza a transformação inversa.

A estrutura Color32 trabalha diretamente com bytes, portanto, eles ocupam menos espaço e não requerem conversão, o que aumenta a eficiência de seu uso. Como armazenamos dados da célula em vez de cores, será mais lógico trabalhar diretamente com os dados brutos da textura, e não com a Color .

HexGrid deve lidar com a criação e inicialização dessas células no shader. Portanto, adicionamos o campo cellShaderData a cellShaderData e criamos um componente dentro do Awake .

  HexCellShaderData cellShaderData; void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; cellShaderData = gameObject.AddComponent<HexCellShaderData>(); CreateMap(cellCountX, cellCountZ); } 

Ao criar um novo mapa, cellShaderData também deve ser cellShaderData .

  public bool CreateMap (int x, int z) { … cellCountX = x; cellCountZ = z; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; cellShaderData.Initialize(cellCountX, cellCountZ); CreateChunks(); CreateCells(); return true; } 

Editando dados da célula


Até agora, ao alterar as propriedades de uma célula, era necessário atualizar um ou vários fragmentos, mas agora pode ser necessário atualizar os dados das células. Isso significa que as células devem ter um link para os dados da célula no sombreador. Para fazer isso, adicione uma propriedade ao HexCell .

  public HexCellShaderData ShaderData { get; set; } 

Em HexGrid.CreateCell , atribuiremos um componente de dados do sombreador a essa propriedade.

  void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); cell.ShaderData = cellShaderData; … } 

Agora podemos obter células para atualizar seus dados de sombreador. Enquanto não estamos rastreando a visibilidade, podemos usar dados de sombreador para outra coisa. O tipo de relevo da célula determina a textura usada para renderizá-la. Como não afeta a geometria da célula, podemos armazenar o índice do tipo de elevação nos dados da célula e não nos dados da malha. Isso nos permitirá livrar-se da necessidade de triangulação ao alterar o tipo de alívio da célula.

Adicione um método HexCellShaderData a RefreshTerrain para simplificar esta tarefa para uma célula específica. Vamos deixar esse método vazio por enquanto.

  public void RefreshTerrain (HexCell cell) { } 

Altere HexCell.TerrainTypeIndex para que ele HexCell.TerrainTypeIndex esse método e não peça para atualizar os fragmentos.

  public int TerrainTypeIndex { get { return terrainTypeIndex; } set { if (terrainTypeIndex != value) { terrainTypeIndex = value; // Refresh(); ShaderData.RefreshTerrain(this); } } } 

Também o chamaremos em HexCell.Load após receber o tipo de topografia da célula.

  public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadByte(); ShaderData.RefreshTerrain(this); elevation = reader.ReadByte(); RefreshPosition(); … } 

Índice de células


Para alterar essas células, precisamos conhecer o índice da célula. A maneira mais fácil de fazer isso é adicionando a propriedade Index ao HexCell . Ele indicará o índice da célula na lista de células no mapa, que corresponde ao seu índice nas células especificadas no shader.

  public int Index { get; set; } 

Esse índice já está em HexGrid.CreateCell , portanto, apenas atribua-o à célula criada.

  void CreateCell (int x, int z, int i) { … cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); cell.Index = i; cell.ShaderData = cellShaderData; … } 

Agora HexCellShaderData.RefreshTerrain pode usar esse índice para especificar dados da célula. Vamos salvar o índice do tipo de elevação no componente alfa de seu pixel, simplesmente convertendo o tipo em byte. Isso suportará até 256 tipos de terreno, o que será suficiente para nós.

  public void RefreshTerrain (HexCell cell) { cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex; } 

Para aplicar dados a uma textura e passá-los para a GPU, precisamos chamar Texture2D.SetPixels32 e, em seguida, Texture2D.Apply . Como no caso de fragmentos, LateUpdate essas operações no LateUpdate para que não possam ser executadas com mais frequência do que uma vez por quadro, independentemente do número de células alteradas.

  public void RefreshTerrain (HexCell cell) { cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex; enabled = true; } void LateUpdate () { cellTexture.SetPixels32(cellTextureData); cellTexture.Apply(); enabled = false; } 

Para garantir que os dados sejam atualizados após a criação de um novo mapa, ative o componente após a inicialização.

  public void Initialize (int x, int z) { … enabled = true; } 

Triangulação dos índices celulares


Como agora armazenamos o índice do tipo de elevação nessas células, não precisamos mais incluí-los no processo de triangulação. Mas, para usar dados da célula, o shader deve saber quais índices usar. Portanto, você precisa armazenar índices de células nos dados da malha, substituindo os índices do tipo de elevação. Além disso, ainda precisamos do canal de cores da malha para misturar células ao usá-las.

useColors campos comuns obsoletos useColors e useTerrainTypes . Substitua-os por um campo useCellData .

 // public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; // public bool useTerrainTypes; public bool useCollider, useCellData, useUVCoordinates, useUV2Coordinates; 

Nós refatoramos a renomeação da lista cellIndices para cellIndices . Vamos também refatorar a renomeação de colors para cellWeights - esse nome será melhor.

 // [NonSerialized] List<Vector3> vertices, terrainTypes; // [NonSerialized] List<Color> colors; [NonSerialized] List<Vector3> vertices, cellIndices; [NonSerialized] List<Color> cellWeights; [NonSerialized] List<Vector2> uvs, uv2s; [NonSerialized] List<int> triangles; 

Altere Clear para que, ao usar essas células, ele reúna duas listas e não separadamente.

  public void Clear () { hexMesh.Clear(); vertices = ListPool<Vector3>.Get(); if (useCellData) { cellWeights = ListPool<Color>.Get(); cellIndices = ListPool<Vector3>.Get(); } // if (useColors) { // colors = ListPool<Color>.Get(); // } if (useUVCoordinates) { uvs = ListPool<Vector2>.Get(); } if (useUV2Coordinates) { uv2s = ListPool<Vector2>.Get(); } // if (useTerrainTypes) { // terrainTypes = ListPool<Vector3>.Get(); // } triangles = ListPool<int>.Get(); } 

Execute o mesmo agrupamento no Apply .

  public void Apply () { hexMesh.SetVertices(vertices); ListPool<Vector3>.Add(vertices); if (useCellData) { hexMesh.SetColors(cellWeights); ListPool<Color>.Add(cellWeights); hexMesh.SetUVs(2, cellIndices); ListPool<Vector3>.Add(cellIndices); } // if (useColors) { // hexMesh.SetColors(colors); // ListPool<Color>.Add(colors); // } if (useUVCoordinates) { hexMesh.SetUVs(0, uvs); ListPool<Vector2>.Add(uvs); } if (useUV2Coordinates) { hexMesh.SetUVs(1, uv2s); ListPool<Vector2>.Add(uv2s); } // if (useTerrainTypes) { // hexMesh.SetUVs(2, terrainTypes); // ListPool<Vector3>.Add(terrainTypes); // } hexMesh.SetTriangles(triangles, 0); ListPool<int>.Add(triangles); hexMesh.RecalculateNormals(); if (useCollider) { meshCollider.sharedMesh = hexMesh; } } 

Vamos remover todos os AddTriangleTerrainTypes e AddTriangleTerrainTypes . Substitua-os AddTriangleCellData métodos AddTriangleCellData apropriados, que adicionam índices e pesos de cada vez.

  public void AddTriangleCellData ( Vector3 indices, Color weights1, Color weights2, Color weights3 ) { cellIndices.Add(indices); cellIndices.Add(indices); cellIndices.Add(indices); cellWeights.Add(weights1); cellWeights.Add(weights2); cellWeights.Add(weights3); } public void AddTriangleCellData (Vector3 indices, Color weights) { AddTriangleCellData(indices, weights, weights, weights); } 

Faça o mesmo no método AddQuad apropriado.

  public void AddQuadCellData ( Vector3 indices, Color weights1, Color weights2, Color weights3, Color weights4 ) { cellIndices.Add(indices); cellIndices.Add(indices); cellIndices.Add(indices); cellIndices.Add(indices); cellWeights.Add(weights1); cellWeights.Add(weights2); cellWeights.Add(weights3); cellWeights.Add(weights4); } public void AddQuadCellData ( Vector3 indices, Color weights1, Color weights2 ) { AddQuadCellData(indices, weights1, weights1, weights2, weights2); } public void AddQuadCellData (Vector3 indices, Color weights) { AddQuadCellData(indices, weights, weights, weights, weights); } 

Refatoração HexGridChunk


Nesta fase, temos muitos erros de compilador no HexGridChunk que precisam ser HexGridChunk . Mas, primeiro, por uma questão de consistência, nós refatoramos o nome das cores estáticas em pesos.

  static Color weights1 = new Color(1f, 0f, 0f); static Color weights2 = new Color(0f, 1f, 0f); static Color weights3 = new Color(0f, 0f, 1f); 

Vamos começar corrigindo TriangulateEdgeFan . Ele costumava precisar de um tipo, mas agora ele precisa de um índice de células. AddTriangleColor código AddTriangleTerrainTypes e AddTriangleTerrainTypes código AddTriangleCellData correspondente.

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float index) { terrain.AddTriangle(center, edge.v1, edge.v2); terrain.AddTriangle(center, edge.v2, edge.v3); terrain.AddTriangle(center, edge.v3, edge.v4); terrain.AddTriangle(center, edge.v4, edge.v5); Vector3 indices; indices.x = indices.y = indices.z = index; terrain.AddTriangleCellData(indices, weights1); terrain.AddTriangleCellData(indices, weights1); terrain.AddTriangleCellData(indices, weights1); terrain.AddTriangleCellData(indices, weights1); // terrain.AddTriangleColor(weights1); // terrain.AddTriangleColor(weights1); // terrain.AddTriangleColor(weights1); // terrain.AddTriangleColor(weights1); // Vector3 types; // types.x = types.y = types.z = type; // terrain.AddTriangleTerrainTypes(types); // terrain.AddTriangleTerrainTypes(types); // terrain.AddTriangleTerrainTypes(types); // terrain.AddTriangleTerrainTypes(types); } 

Este método é chamado em vários lugares. Vamos examiná-los e garantir que o índice da célula seja transferido para lá, e não o tipo de terreno.

  TriangulateEdgeFan(center, e, cell.Index); 

Em seguida é TriangulateEdgeStrip . Tudo é um pouco mais complicado aqui, mas usamos a mesma abordagem. Renomeie também o fator de refatoração dos nomes de parâmetros c1 e c2 para w1 e w2 .

  void TriangulateEdgeStrip ( EdgeVertices e1, Color w1, float index1, EdgeVertices e2, Color w2, float index2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); Vector3 indices; indices.x = indices.z = index1; indices.y = index2; terrain.AddQuadCellData(indices, w1, w2); terrain.AddQuadCellData(indices, w1, w2); terrain.AddQuadCellData(indices, w1, w2); terrain.AddQuadCellData(indices, w1, w2); // terrain.AddQuadColor(c1, c2); // terrain.AddQuadColor(c1, c2); // terrain.AddQuadColor(c1, c2); // terrain.AddQuadColor(c1, c2); // Vector3 types; // types.x = types.z = type1; // types.y = type2; // terrain.AddQuadTerrainTypes(types); // terrain.AddQuadTerrainTypes(types); // terrain.AddQuadTerrainTypes(types); // terrain.AddQuadTerrainTypes(types); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } } 

Altere as chamadas para esse método para que o índice da célula seja passado para elas. Também mantemos os nomes das variáveis ​​consistentes.

  TriangulateEdgeStrip( m, weights1, cell.Index, e, weights1, cell.Index ); … TriangulateEdgeStrip( e1, weights1, cell.Index, e2, weights2, neighbor.Index, hasRoad ); … void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color w2 = HexMetrics.TerraceLerp(weights1, weights2, 1); float i1 = beginCell.Index; float i2 = endCell.Index; TriangulateEdgeStrip(begin, weights1, i1, e2, w2, i2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color w1 = w2; e2 = EdgeVertices.TerraceLerp(begin, end, i); w2 = HexMetrics.TerraceLerp(weights1, weights2, i); TriangulateEdgeStrip(e1, w1, i1, e2, w2, i2, hasRoad); } TriangulateEdgeStrip(e2, w2, i1, end, weights2, i2, hasRoad); } 

Agora vamos para os métodos dos ângulos. Essas alterações são simples, mas precisam ser feitas em uma grande quantidade de código. Primeiro no TriangulateCorner .

  void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); Vector3 indices; indices.x = bottomCell.Index; indices.y = leftCell.Index; indices.z = rightCell.Index; terrain.AddTriangleCellData(indices, weights1, weights2, weights3); // terrain.AddTriangleColor(weights1, weights2, weights3); // Vector3 types; // types.x = bottomCell.TerrainTypeIndex; // types.y = leftCell.TerrainTypeIndex; // types.z = rightCell.TerrainTypeIndex; // terrain.AddTriangleTerrainTypes(types); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); } 

Mais em TriangulateCornerTerraces .

  void TriangulateCornerTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1); Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1); Color w3 = HexMetrics.TerraceLerp(weights1, weights2, 1); Color w4 = HexMetrics.TerraceLerp(weights1, weights3, 1); Vector3 indices; indices.x = beginCell.Index; indices.y = leftCell.Index; indices.z = rightCell.Index; terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleCellData(indices, weights1, w3, w4); // terrain.AddTriangleColor(weights1, w3, w4); // terrain.AddTriangleTerrainTypes(indices); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color w1 = w3; Color w2 = w4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); w3 = HexMetrics.TerraceLerp(weights1, weights2, i); w4 = HexMetrics.TerraceLerp(weights1, weights3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadCellData(indices, w1, w2, w3, w4); // terrain.AddQuadColor(w1, w2, w3, w4); // terrain.AddQuadTerrainTypes(indices); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadCellData(indices, w3, w4, weights2, weights3); // terrain.AddQuadColor(w3, w4, weights2, weights3); // terrain.AddQuadTerrainTypes(indices); } 

Em seguida, em TriangulateCornerTerracesCliff .

  void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (rightCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b ); Color boundaryWeights = Color.Lerp(weights1, weights3, b); Vector3 indices; indices.x = beginCell.Index; indices.y = leftCell.Index; indices.z = rightCell.Index; TriangulateBoundaryTriangle( begin, weights1, left, weights2, boundary, boundaryWeights, indices ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, weights2, right, weights3, boundary, boundaryWeights, indices ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleCellData( indices, weights2, weights3, boundaryWeights ); // terrain.AddTriangleColor(weights2, weights3, boundaryColor); // terrain.AddTriangleTerrainTypes(indices); } } 

E um pouco diferente no TriangulateCornerCliffTerraces .

  void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (leftCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b ); Color boundaryWeights = Color.Lerp(weights1, weights2, b); Vector3 indices; indices.x = beginCell.Index; indices.y = leftCell.Index; indices.z = rightCell.Index; TriangulateBoundaryTriangle( right, weights3, begin, weights1, boundary, boundaryWeights, indices ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, weights2, right, weights3, boundary, boundaryWeights, indices ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleCellData( indices, weights2, weights3, boundaryWeights ); // terrain.AddTriangleColor(weights2, weights3, boundaryWeights); // terrain.AddTriangleTerrainTypes(indices); } } 

Os dois métodos anteriores usam o TriangulateBoundaryTriangle , que também requer atualização.

  void TriangulateBoundaryTriangle ( Vector3 begin, Color beginWeights, Vector3 left, Color leftWeights, Vector3 boundary, Color boundaryWeights, Vector3 indices ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleCellData(indices, beginWeights, w2, boundaryWeights); // terrain.AddTriangleColor(beginColor, c2, boundaryColor); // terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color w1 = w2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleCellData(indices, w1, w2, boundaryWeights); // terrain.AddTriangleColor(c1, c2, boundaryColor); // terrain.AddTriangleTerrainTypes(types); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleCellData(indices, w2, leftWeights, boundaryWeights); // terrain.AddTriangleColor(c2, leftColor, boundaryColor); // terrain.AddTriangleTerrainTypes(types); } 

O método final que precisa ser alterado é TriangulateWithRiver .

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … terrain.AddTriangle(centerL, m.v1, m.v2); terrain.AddQuad(centerL, center, m.v2, m.v3); terrain.AddQuad(center, centerR, m.v3, m.v4); terrain.AddTriangle(centerR, m.v4, m.v5); Vector3 indices; indices.x = indices.y = indices.z = cell.Index; terrain.AddTriangleCellData(indices, weights1); terrain.AddQuadCellData(indices, weights1); terrain.AddQuadCellData(indices, weights1); terrain.AddTriangleCellData(indices, weights1); // terrain.AddTriangleColor(weights1); // terrain.AddQuadColor(weights1); // terrain.AddQuadColor(weights1); // terrain.AddTriangleColor(weights1); // Vector3 types; // types.x = types.y = types.z = cell.TerrainTypeIndex; // terrain.AddTriangleTerrainTypes(types); // terrain.AddQuadTerrainTypes(types); // terrain.AddQuadTerrainTypes(types); // terrain.AddTriangleTerrainTypes(types); … } 

Para que tudo funcione, precisamos indicar que usaremos os dados da célula para o elemento filho do alívio do fragmento pré-fabricado.


O relevo usa dados da célula.

Nesse estágio, a malha contém índices de células em vez de índices do tipo de elevação. Como o shader de elevação ainda os interpreta como índices de elevação, veremos que a primeira célula é renderizada com a primeira textura e assim por diante até que a última textura de relevo seja atingida.


Usando índices de célula como índices de textura de elevação.

Não consigo fazer o código refatorado funcionar. O que estou fazendo de errado?
Ao mesmo tempo, alteramos uma grande quantidade de código de triangulação, para que haja uma alta probabilidade de erros ou omissões. Se você não encontrar o erro, tente baixar o pacote desta seção e extraia os arquivos apropriados. Você pode importá-los para um projeto separado e comparar com seu próprio código.

Transferir dados da célula para um sombreador


Para usar essas células, o shader do terreno deve ter acesso a elas. Isso pode ser implementado através da propriedade shader. Isso exigirá que HexCellShaderData defina a propriedade do material do relevo. Ou podemos tornar a textura dessas células globalmente visível para todos os shaders. Isso é conveniente porque precisamos dele em vários shaders, portanto, usaremos essa abordagem.

Após criar a textura da célula, chame o método estático Shader.SetGlobalTexture para torná-lo globalmente visível como _HexCellData .

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

Ao usar a propriedade shader, o Unity disponibiliza o tamanho da textura para o shader por meio da variável textureName_TexelSize . Este é um vetorizador de quatro componentes que contém valores inversos à largura e altura, bem como a largura e a altura em si. Mas, ao definir a textura global, isso não é realizado. Portanto, nós mesmos faremos isso usando o Shader.SetGlobalVector após criar ou redimensionar a textura.

  else { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point; cellTexture.wrapMode = TextureWrapMode.Clamp; Shader.SetGlobalTexture("_HexCellData", cellTexture); } Shader.SetGlobalVector( "_HexCellData_TexelSize", new Vector4(1f / x, 1f / z, x, z) ); 

Acesso a dados de sombreador


Crie um novo arquivo de inclusão de sombreador na pasta de materiais chamada HexCellData . Dentro dele, definimos variáveis ​​para obter informações sobre a textura e o tamanho dessas células. Também criamos uma função para obter os dados da célula para os dados de malha de vértice fornecidos.

 sampler2D _HexCellData; float4 _HexCellData_TexelSize; float4 GetCellData (appdata_full v) { } 


Novo arquivo de inclusão.

Os índices de células são armazenados no v.texcoord2 , como foi o caso dos tipos de terreno. Vamos começar com o primeiro índice - v.texcoord2.x . Infelizmente, não podemos usar diretamente o índice para provar a textura dessas células. Teremos que convertê-lo em coordenadas UV.

A primeira etapa na criação da coordenada U é dividir o índice da célula pela largura da textura. Podemos fazer isso multiplicando-o por _HexCellData_TexelSize.x .

 float4 GetCellData (appdata_full v) { float2 uv; uv.x = v.texcoord2.x * _HexCellData_TexelSize.x; } 

O resultado será um número no formato ZU, em que Z é o índice da linha e U é a coordenada da célula U. Podemos extrair a string arredondando o número para baixo e subtraindo-o do número para obter a coordenada U.

 float4 GetCellData (appdata_full v) { float2 uv; uv.x = v.texcoord2.x * _HexCellData_TexelSize.x; float row = floor(uv.x); uv.x -= row; } 

A coordenada V está dividindo a linha pela altura da textura.

 float4 GetCellData (appdata_full v) { float2 uv; uv.x = v.texcoord2.x * _HexCellData_TexelSize.x; float row = floor(uv.x); uv.x -= row; uv.y = row * _HexCellData_TexelSize.y; } 

Como estamos amostrando a textura, precisamos usar as coordenadas no centro dos pixels, não nas bordas. Dessa forma, garantimos que os pixels corretos sejam amostrados. Portanto, depois de dividir pelo tamanho da textura, adicione ½.

 float4 GetCellData (appdata_full v) { float2 uv; uv.x = (v.texcoord2.x + 0.5) * _HexCellData_TexelSize.x; float row = floor(uv.x); uv.x -= row; uv.y = (row + 0.5) * _HexCellData_TexelSize.y; } 

Isso nos fornece as coordenadas UV corretas para o índice da primeira célula armazenada nos dados do vértice. Mas, no topo, podemos ter até três índices diferentes. Portanto, faremos o GetCellDatatrabalho para qualquer índice. Adicione um parâmetro inteiro a ele index, que usaremos para acessar o componente vetorial com o índice de células.

 float4 GetCellData (appdata_full v, int index) { float2 uv; uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x; float row = floor(uv.x); uv.x -= row; uv.y = (row + 0.5) * _HexCellData_TexelSize.y; } 

Agora que temos todas as coordenadas necessárias para essas células, podemos fazer uma amostra _HexCellData. Como estamos amostrando a textura no programa de vértices, precisamos informar explicitamente ao sombreador qual textura mip usar. Isso pode ser feito usando uma função tex2Dlodque requer as coordenadas de quatro texturas. Como essas células não possuem texturas mip, atribuímos valores zero às coordenadas extras.

 float4 GetCellData (appdata_full v, int index) { float2 uv; uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x; float row = floor(uv.x); uv.x -= row; uv.y = (row + 0.5) * _HexCellData_TexelSize.y; float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0)); } 

O quarto componente de dados contém um índice do tipo de elevação, que armazenamos diretamente como bytes. No entanto, a GPU o converteu automaticamente em um valor de ponto flutuante no intervalo de 0 a 1. Para convertê-lo novamente no valor correto, multiplique-o por 255. Depois disso, você pode retornar os dados.

  float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0)); data.w *= 255; return data; 

Para usar essa funcionalidade, ative o HexCellData no shader Terrain . Desde que coloquei esse shader em Materiais / Terreno , preciso usar o caminho relativo ../HexCellData.cginc .

  #include "../HexCellData.cginc" UNITY_DECLARE_TEX2DARRAY(_MainTex); 

No programa de vértice, obtemos dados de célula para todos os três índices de célula armazenados nos dados de vértice. Em seguida, atribua data.terrainseus índices de elevação.

  void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); // data.terrain = v.texcoord2.xyz; float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); float4 cell2 = GetCellData(v, 2); data.terrain.x = cell0.w; data.terrain.y = cell1.w; data.terrain.z = cell2.w; } 

Nesse ponto, o mapa novamente começou a exibir o terreno correto. A grande diferença é que editar apenas tipos de terreno não leva mais a novas triangulações. Se durante a edição qualquer outro dado da célula for alterado, a triangulação será realizada como de costume.

unitypackage

Visibilidade


Tendo criado a base dessas células, podemos avançar para dar suporte à visibilidade. Para fazer isso, usamos o sombreador, as próprias células e os objetos que determinam a visibilidade. Observe que o processo de triangulação não sabe absolutamente nada sobre isso.

Shader


Vamos começar dizendo ao shader Terrain sobre visibilidade. Ele receberá dados de visibilidade do programa de vértices e os passa para o programa de fragmentos usando a estrutura Input. Como passamos três índices de elevação separados, também passaremos três valores de visibilidade.

  struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float3 visibility; }; 

Para armazenar visibilidade, usamos o primeiro componente dessas células.

  void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); float4 cell2 = GetCellData(v, 2); data.terrain.x = cell0.w; data.terrain.y = cell1.w; data.terrain.z = cell2.w; data.visibility.x = cell0.x; data.visibility.y = cell1.x; data.visibility.z = cell2.x; } 

Uma visibilidade de 0 significa que a célula está invisível no momento. Se fosse visível, teria o valor de visibilidade 1. Portanto, podemos escurecer o terreno multiplicando o resultado GetTerrainColorpelo vetor de visibilidade correspondente. Assim, modulamos individualmente a cor do relevo de cada célula mista.

  float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]); float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw); return c * (IN.color[index] * IN.visibility[index]); } 


As células ficaram pretas.

Em vez disso, não podemos combinar a visibilidade em um programa de vértices?
, . . . , . , .

A escuridão completa é um fracasso para células temporariamente invisíveis. Para que ainda possamos ver o alívio, precisamos aumentar o indicador usado para células ocultas. Vamos passar de 0–1 para ¼ - 1, o que pode ser feito usando a função lerpno final do programa de vértices.

  void vert (inout appdata_full v, out Input data) { … data.visibility.x = cell0.x; data.visibility.y = cell1.x; data.visibility.z = cell2.x; data.visibility = lerp(0.25, 1, data.visibility); } 


Células sombreadas.

Rastreamento de visibilidade de célula


Para que a visibilidade funcione, as células devem rastrear sua visibilidade. Mas como uma célula determina se é visível? Podemos fazer isso rastreando o número de entidades que o veem. Quando alguém começa a ver uma célula, ele deve denunciá-la. E quando alguém para de ver a célula, também deve notificá-la sobre isso. A célula simplesmente controla o número de observadores, quaisquer que sejam essas entidades. Se uma célula tiver um valor de visibilidade de pelo menos 1, será visível, caso contrário, será invisível. Para implementar esse comportamento, adicionamos HexCelldois métodos e uma propriedade à variável

  public bool IsVisible { get { return visibility > 0; } } … int visibility; … public void IncreaseVisibility () { visibility += 1; } public void DecreaseVisibility () { visibility -= 1; } 

Em seguida, adicione ao HexCellShaderDatamétodo RefreshVisibility, que faz a mesma coisa que RefreshTerrain, apenas por uma questão de visibilidade. Salve os dados no componente R das células de dados. Como trabalhamos com bytes que são convertidos nos valores de 0 a 1, usamos para indicar visibilidade (byte)255.

  public void RefreshVisibility (HexCell cell) { cellTextureData[cell.Index].r = cell.IsVisible ? (byte)255 : (byte)0; enabled = true; } 

Vamos chamar esse método com visibilidade crescente e decrescente, alterando o valor entre 0 e 1.

  public void IncreaseVisibility () { visibility += 1; if (visibility == 1) { ShaderData.RefreshVisibility(this); } } public void DecreaseVisibility () { visibility -= 1; if (visibility == 0) { ShaderData.RefreshVisibility(this); } } 

Criando visibilidade do esquadrão


Vamos fazer com que as unidades possam ver a célula que ocupam. Isso é feito usando uma chamada IncreaseVisibilitypara o novo local da unidade durante a tarefa HexUnit.Location. Também pedimos o local antigo (se existir) DecreaseVisibility.

  public HexCell Location { get { return location; } set { if (location) { location.DecreaseVisibility(); location.Unit = null; } location = value; value.Unit = this; value.IncreaseVisibility(); transform.localPosition = value.Position; } } 


As unidades podem ver onde estão.

Finalmente, usamos visibilidade! Quando adicionadas a um mapa, as unidades tornam sua célula visível. Além disso, seu escopo é teleportado quando se muda para seu novo local. Mas seu escopo permanece ativo ao remover unidades do mapa. Para consertar isso, reduziremos a visibilidade de sua localização ao destruir unidades.

  public void Die () { if (location) { location.DecreaseVisibility(); } location.Unit = null; Destroy(gameObject); } 

Faixa de visibilidade


Até agora, vemos apenas a célula na qual o desapego está localizado, e isso limita as possibilidades. Pelo menos precisamos ver as células vizinhas. No caso geral, as unidades podem ver todas as células a uma certa distância, o que depende da unidade.

Vamos adicionar ao HexGridmétodo para encontrar todas as células visíveis de uma célula, levando em consideração o intervalo. Podemos criar esse método duplicando e alterando Search. Altere seus parâmetros e faça com que ele retorne uma lista de células para as quais você pode usar o pool de listas.

A cada iteração, a célula atual é adicionada à lista. Como não há mais célula final, a pesquisa nunca terminará quando chegar a esse ponto. Também nos livramos da lógica dos movimentos e do custo dos movimentos. Faça as propriedadesPathFromeles não foram mais solicitados porque não precisamos deles e não queremos interferir no caminho ao longo da grade.

A cada passo, a distância simplesmente aumenta em 1. Se exceder o intervalo, essa célula será ignorada. E como não precisamos de uma heurística de pesquisa, a inicializamos com um valor igual a 0. Ou seja, retornamos ao algoritmo Dijkstra.

  List<HexCell> GetVisibleCells (HexCell fromCell, int range) { List<HexCell> visibleCells = ListPool<HexCell>.Get(); searchFrontierPhase += 2; if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; visibleCells.Add(current); // if (current == toCell) { // return true; // } // int currentTurn = (current.Distance - 1) / speed; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } // … // int moveCost; // … int distance = current.Distance + 1; if (distance > range) { continue; } // int turn = (distance - 1) / speed; // if (turn > currentTurn) { // distance = turn * speed + moveCost; // } if (neighbor.SearchPhase < searchFrontierPhase) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = distance; // neighbor.PathFrom = current; neighbor.SearchHeuristic = 0; searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; // neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } } } return visibleCells; } 

Não podemos usar um algoritmo mais simples para encontrar todas as células dentro do alcance?
, , .

Adicione também HexGridmétodos IncreaseVisibilitye DecreaseVisibility. Eles obtêm a célula e o alcance, fazem uma lista das células correspondentes e aumentam / diminuem sua visibilidade. Quando terminar, eles devem retornar a lista de volta ao seu pool.

  public void IncreaseVisibility (HexCell fromCell, int range) { List<HexCell> cells = GetVisibleCells(fromCell, range); for (int i = 0; i < cells.Count; i++) { cells[i].IncreaseVisibility(); } ListPool<HexCell>.Add(cells); } public void DecreaseVisibility (HexCell fromCell, int range) { List<HexCell> cells = GetVisibleCells(fromCell, range); for (int i = 0; i < cells.Count; i++) { cells[i].DecreaseVisibility(); } ListPool<HexCell>.Add(cells); } 

Para usar esses métodos HexUnitrequer acesso à grade, adicione uma propriedade a ela Grid.

  public HexGrid Grid { get; set; } 

Quando você adiciona um esquadrão a uma grade, ele atribui uma grade a essa propriedade 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; } 

Para começar, uma gama de visibilidade de três células será suficiente. Para fazer isso, adicionamos à HexUnitconstante, que no futuro sempre pode se transformar em uma variável. Em seguida, faremos com que o esquadrão invoque métodos para a grade IncreaseVisibilitye DecreaseVisibility, transmitindo também seu alcance de visibilidade, e não apenas vá para este local.

  const int visionRange = 3; … public HexCell Location { get { return location; } set { if (location) { // location.DecreaseVisibility(); Grid.DecreaseVisibility(location, visionRange); location.Unit = null; } location = value; value.Unit = this; // value.IncreaseVisibility(); Grid.IncreaseVisibility(value, visionRange); transform.localPosition = value.Position; } } … public void Die () { if (location) { // location.DecreaseVisibility(); Grid.DecreaseVisibility(location, visionRange); } location.Unit = null; Destroy(gameObject); } 


Unidades com faixa de visibilidade que podem se sobrepor.

Visibilidade ao mover


No momento, a área de visibilidade do esquadrão após o comando de movimento é teleportada imediatamente para o ponto final. Seria melhor se a unidade e seu campo de visibilidade se movessem juntos. O primeiro passo para isso é que não definiremos mais a propriedade Locationc HexUnit.Travel. Em vez disso, alteraremos diretamente o campo location, evitando o código de propriedade. Portanto, limparemos manualmente o local antigo e configuraremos um novo local. A visibilidade permanecerá inalterada.

  public void Travel (List<HexCell> path) { // Location = path[path.Count - 1]; location.Unit = null; location = path[path.Count - 1]; location.Unit = this; pathToTravel = path; StopAllCoroutines(); StartCoroutine(TravelPath()); } 

Dentro das corotinas, TravelPathreduziremos a visibilidade da primeira célula somente após a conclusão LookAt. Depois disso, antes de passar para uma nova célula, aumentaremos a visibilidade dessa célula. Depois de terminar, reduzimos novamente a visibilidade. Por fim, aumente a visibilidade da última célula.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; // transform.localPosition = c; yield return LookAt(pathToTravel[1].Position); Grid.DecreaseVisibility(pathToTravel[0], visionRange); float t = Time.deltaTime * travelSpeed; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; Grid.IncreaseVisibility(pathToTravel[i], visionRange); for (; t < 1f; t += Time.deltaTime * travelSpeed) { … } Grid.DecreaseVisibility(pathToTravel[i], visionRange); t -= 1f; } a = c; b = location.Position; // We can simply use the destination here. c = b; Grid.IncreaseVisibility(location, visionRange); for (; t < 1f; t += Time.deltaTime * travelSpeed) { … } … } 


Visibilidade em movimento.

Tudo isso funciona, exceto quando uma nova ordem é emitida no momento em que o desapego se move. Isso leva ao teletransporte, que também deve se aplicar à visibilidade. Para perceber isso, precisamos rastrear a localização atual do esquadrão enquanto estiver em movimento.

  HexCell location, currentTravelLocation; 

Atualizaremos esse local sempre que atingirmos uma nova célula enquanto estiver em movimento, até que o esquadrão atinja a célula final. Então ele deve ser redefinido.

  IEnumerator TravelPath () { … 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); for (; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); Vector3 d = Bezier.GetDerivative(a, b, c, t); dy = 0f; transform.localRotation = Quaternion.LookRotation(d); yield return null; } Grid.DecreaseVisibility(pathToTravel[i], visionRange); t -= 1f; } currentTravelLocation = null; … } 

Agora, após concluir a entrega, TravelPathpodemos verificar se a localização intermediária antiga do caminho é conhecida. Se sim, você precisará reduzir a visibilidade nesta célula, e não no início do caminho.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position); Grid.DecreaseVisibility( currentTravelLocation ? currentTravelLocation : pathToTravel[0], visionRange ); … } 

Também precisamos corrigir a visibilidade após a recompilação que ocorreu durante o movimento do esquadrão. Se o local intermediário ainda for conhecido, reduza a visibilidade nele e aumente a visibilidade no ponto final e, em seguida, redefina o local intermediário.

  void OnEnable () { if (location) { transform.localPosition = location.Position; if (currentTravelLocation) { Grid.IncreaseVisibility(location, visionRange); Grid.DecreaseVisibility(currentTravelLocation, visionRange); currentTravelLocation = null; } } } 

unitypackage

Visibilidade das estradas e da água


Embora as alterações de cores de relevo sejam baseadas na visibilidade, isso não afeta as estradas e a água. Eles parecem muito brilhantes para células invisíveis. Para aplicar visibilidade às estradas e à água, precisamos adicionar índices de células e misturar pesos aos dados da malha. Portanto, verificaremos os filhos dos Dados da célula de uso nos rios , estradas , água , margem da água e estuários do fragmento pré - fabricado.

Estradas


Vamos começar pelas estradas. O método é HexGridChunk.TriangulateRoadEdgeusado para criar uma pequena parte da estrada no centro da célula, portanto, ele precisa de um índice de célula. Adicione um parâmetro a ele e gere dados da célula para o triângulo.

  void TriangulateRoadEdge ( Vector3 center, Vector3 mL, Vector3 mR, float index ) { roads.AddTriangle(center, mL, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); Vector3 indices; indices.x = indices.y = indices.z = index; roads.AddTriangleCellData(indices, weights1); } 

Outra maneira fácil de criar estradas é TriangulateRoadSegment. É usado tanto dentro como entre as células, portanto, ele deve funcionar com dois índices diferentes. Para isso, é conveniente usar o parâmetro do vetor de índice. Como os segmentos de estrada podem ser partes de bordas, os pesos também devem ser passados ​​por parâmetros.

  void TriangulateRoadSegment ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, Vector3 v5, Vector3 v6, Color w1, Color w2, Vector3 indices ) { roads.AddQuad(v1, v2, v4, v5); roads.AddQuad(v2, v3, v5, v6); roads.AddQuadUV(0f, 1f, 0f, 0f); roads.AddQuadUV(1f, 0f, 0f, 0f); roads.AddQuadCellData(indices, w1, w2); roads.AddQuadCellData(indices, w1, w2); } 

Agora vamos seguir em frente TriangulateRoad, o que cria estradas dentro das células. Ele também precisa de um parâmetro de índice. Ele passa esses dados para os métodos de estrada que chama e os adiciona aos triângulos que cria.

  void TriangulateRoad ( Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e, bool hasRoadThroughCellEdge, float index ) { if (hasRoadThroughCellEdge) { Vector3 indices; indices.x = indices.y = indices.z = index; Vector3 mC = Vector3.Lerp(mL, mR, 0.5f); TriangulateRoadSegment( mL, mC, mR, e.v2, e.v3, e.v4, weights1, weights1, indices ); roads.AddTriangle(center, mL, mC); roads.AddTriangle(center, mC, mR); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); roads.AddTriangleUV( new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); roads.AddTriangleCellData(indices, weights1); roads.AddTriangleCellData(indices, weights1); } else { TriangulateRoadEdge(center, mL, mR, index); } } 

Resta acrescentar os argumentos de método necessários TriangulateRoad, TriangulateRoadEdgee TriangulateRoadSegmentpara corrigir todos os erros do compilador.

  void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, cell.Index); if (cell.HasRoads) { Vector2 interpolators = GetRoadInterpolators(direction, cell); TriangulateRoad( center, Vector3.Lerp(center, e.v1, interpolators.x), Vector3.Lerp(center, e.v5, interpolators.y), e, cell.HasRoadThroughEdge(direction), cell.Index ); } } … void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge, cell.Index); if (previousHasRiver) { TriangulateRoadEdge(roadCenter, center, mL, cell.Index); } if (nextHasRiver) { TriangulateRoadEdge(roadCenter, mR, center, cell.Index); } } … void TriangulateEdgeStrip () { … if (hasRoad) { TriangulateRoadSegment( e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4, w1, w2, indices ); } } 

Agora os dados da malha estão corretos e passaremos para o shader Road . Ele precisa de um programa de vértice e deve conter HexCellData .

  #pragma surface surf Standard fullforwardshadows decal:blend vertex:vert #pragma target 3.0 #include "HexCellData.cginc" 

Como não misturamos vários materiais, será suficiente transmitir um indicador de visibilidade ao programa de fragmentos.

  struct Input { float2 uv_MainTex; float3 worldPos; float visibility; }; 

É suficiente para um novo programa de vértice receber dados de duas células. Nós imediatamente misturamos sua visibilidade, ajustamos e adicionamos à saída.

  void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); data.visibility = cell0.x * v.color.x + cell1.x * v.color.y; data.visibility = lerp(0.25, 1, data.visibility); } 

No programa de fragmentos, precisamos apenas adicionar visibilidade à cor.

  void surf (Input IN, inout SurfaceOutputStandard o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025); fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility); … } 


Estradas com visibilidade.

Água aberta


Pode parecer que a visibilidade já afetou a água, mas esta é apenas a superfície de um terreno imerso na água. Vamos começar aplicando visibilidade à água aberta. Para isso, precisamos mudar HexGridChunk.TriangulateOpenWater.

  void TriangulateOpenWater ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … water.AddTriangle(center, c1, c2); Vector3 indices; indices.x = indices.y = indices.z = cell.Index; water.AddTriangleCellData(indices, weights1); if (direction <= HexDirection.SE && neighbor != null) { … water.AddQuad(c1, c2, e1, e2); indices.y = neighbor.Index; water.AddQuadCellData(indices, weights1, weights2); if (direction <= HexDirection.E) { … water.AddTriangle( c2, e2, c2 + HexMetrics.GetWaterBridge(direction.Next()) ); indices.z = nextNeighbor.Index; water.AddTriangleCellData( indices, weights1, weights2, weights3 ); } } } 

Também precisamos adicionar dados de células aos ventiladores dos triângulos próximos à costa.

  void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … water.AddTriangle(center, e1.v1, e1.v2); water.AddTriangle(center, e1.v2, e1.v3); water.AddTriangle(center, e1.v3, e1.v4); water.AddTriangle(center, e1.v4, e1.v5); Vector3 indices; indices.x = indices.y = indices.z = cell.Index; water.AddTriangleCellData(indices, weights1); water.AddTriangleCellData(indices, weights1); water.AddTriangleCellData(indices, weights1); water.AddTriangleCellData(indices, weights1); … } 

O sombreador de água precisa ser alterado da mesma forma que o sombreador de estrada , mas precisa combinar a visibilidade de não duas, mas três células.

  #pragma surface surf Standard alpha vertex:vert #pragma target 3.0 #include "Water.cginc" #include "HexCellData.cginc" sampler2D _MainTex; struct Input { float2 uv_MainTex; float3 worldPos; float visibility; }; … void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); float4 cell2 = GetCellData(v, 2); data.visibility = cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z; data.visibility = lerp(0.25, 1, data.visibility); } void surf (Input IN, inout SurfaceOutputStandard o) { float waves = Waves(IN.worldPos.xz, _MainTex); fixed4 c = saturate(_Color + waves); o.Albedo = c.rgb * IN.visibility; … } 


Água aberta com visibilidade.

Costa e estuário


Para apoiar a costa, precisamos mudar novamente HexGridChunk.TriangulateWaterShore. Já criamos um vetor de índice, mas usamos apenas um índice de células para águas abertas. A costa também precisa de um índice de vizinhos, portanto, altere o código.

  Vector3 indices; // indices.x = indices.y = indices.z = cell.Index; indices.x = indices.z = cell.Index; indices.y = neighbor.Index; 

Adicione os dados da célula aos quadriláteros e ao triângulo da costa. Também passamos os índices na chamada TriangulateEstuary.

  if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.IncomingRiver == direction, indices ); } else { … waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadCellData(indices, weights1, weights2); waterShore.AddQuadCellData(indices, weights1, weights2); waterShore.AddQuadCellData(indices, weights1, weights2); waterShore.AddQuadCellData(indices, weights1, weights2); } HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { … waterShore.AddTriangleUV( … ); indices.z = nextNeighbor.Index; waterShore.AddTriangleCellData( indices, weights1, weights2, weights3 ); } 

Adicione o parâmetro necessário TriangulateEstuarye cuide dessas células para a costa e a boca. Não esqueça que a boca é feita de trapézio com dois triângulos da costa nas laterais. Garantimos que os pesos sejam transferidos na ordem correta.

  void TriangulateEstuary ( EdgeVertices e1, EdgeVertices e2, bool incomingRiver, Vector3 indices ) { waterShore.AddTriangle(e2.v1, e1.v2, e1.v1); waterShore.AddTriangle(e2.v5, e1.v5, e1.v4); waterShore.AddTriangleUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); waterShore.AddTriangleUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); waterShore.AddTriangleCellData(indices, weights2, weights1, weights1); waterShore.AddTriangleCellData(indices, weights2, weights1, weights1); estuaries.AddQuad(e2.v1, e1.v2, e2.v2, e1.v3); estuaries.AddTriangle(e1.v3, e2.v2, e2.v4); estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5); estuaries.AddQuadUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0f, 0f) ); estuaries.AddTriangleUV( new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(1f, 1f) ); estuaries.AddQuadUV( new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f) ); estuaries.AddQuadCellData( indices, weights2, weights1, weights2, weights1 ); estuaries.AddTriangleCellData(indices, weights1, weights2, weights2); estuaries.AddQuadCellData(indices, weights1, weights2); … } 

No sombreador WaterShore , é necessário fazer as mesmas alterações que no sombreador Water , misturando a visibilidade das três células.

  #pragma surface surf Standard alpha vertex:vert #pragma target 3.0 #include "Water.cginc" #include "HexCellData.cginc" sampler2D _MainTex; struct Input { float2 uv_MainTex; float3 worldPos; float visibility; }; … void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); float4 cell2 = GetCellData(v, 2); data.visibility = cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z; data.visibility = lerp(0.25, 1, data.visibility); } void surf (Input IN, inout SurfaceOutputStandard o) { … fixed4 c = saturate(_Color + max(foam, waves)); o.Albedo = c.rgb * IN.visibility; … } 

O sombreador do estuário mistura a visibilidade de duas células, assim como o sombreador de estrada . Ele já tem um programa de vértices, porque precisamos que ele transmita as coordenadas UV dos rios.

  #include "Water.cginc" #include "HexCellData.cginc" sampler2D _MainTex; struct Input { float2 uv_MainTex; float2 riverUV; float3 worldPos; float visibility; }; half _Glossiness; half _Metallic; fixed4 _Color; void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); o.riverUV = v.texcoord1.xy; float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); o.visibility = cell0.x * v.color.x + cell1.x * v.color.y; o.visibility = lerp(0.25, 1, o.visibility); } void surf (Input IN, inout SurfaceOutputStandard o) { … fixed4 c = saturate(_Color + water); o.Albedo = c.rgb * IN.visibility; … } 


Costa e estuário com visibilidade.

Rivers


As últimas regiões de água para trabalhar são os rios. Adicione um HexGridChunk.TriangulateRiverQuadvetor de índice ao parâmetro e adicione-o à malha para manter a visibilidade de duas células.

  void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y, float v, bool reversed, Vector3 indices ) { TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed, indices); } void TriangulateRiverQuad ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float v, bool reversed, Vector3 indices ) { … rivers.AddQuadCellData(indices, weights1, weights2); } 

TriangulateWithRiverBeginOrEndcria pontos finais do rio com um quad e um triângulo no centro da célula. Adicione os dados de célula necessários para isso.

  void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater) { bool reversed = cell.HasIncomingRiver; Vector3 indices; indices.x = indices.y = indices.z = cell.Index; TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed, indices ); center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY; rivers.AddTriangle(center, m.v2, m.v4); … rivers.AddTriangleCellData(indices, weights1); } } 

Já temos esses índices de células TriangulateWithRiver, então apenas os passamos na chamada TriangulateRiverQuad.

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater) { bool reversed = cell.IncomingRiver == direction; TriangulateRiverQuad( centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, 0.4f, reversed, indices ); TriangulateRiverQuad( m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed, indices ); } } 

Também adicionamos suporte de índice a cachoeiras que caem em águas profundas.

  void TriangulateWaterfallInWater ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float waterY, Vector3 indices ) { … rivers.AddQuadCellData(indices, weights1, weights2); } 

E, finalmente, altere-o TriangulateConnectionpara que transmita os índices necessários aos métodos de rios e cachoeiras.

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (hasRiver) { e2.v3.y = neighbor.StreamBedY; Vector3 indices; indices.x = indices.z = cell.Index; indices.y = neighbor.Index; if (!cell.IsUnderwater) { if (!neighbor.IsUnderwater) { TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } else if (cell.Elevation > neighbor.WaterLevel) { TriangulateWaterfallInWater( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, neighbor.WaterSurfaceY, indices ); } } else if ( !neighbor.IsUnderwater && neighbor.Elevation > cell.WaterLevel ) { TriangulateWaterfallInWater( e2.v4, e2.v2, e1.v4, e1.v2, neighbor.RiverSurfaceY, cell.RiverSurfaceY, cell.WaterSurfaceY, indices ); } } … } 

O shader do rio precisa fazer as mesmas alterações que o shader da estrada .

  #pragma surface surf Standard alpha vertex:vert #pragma target 3.0 #include "Water.cginc" #include "HexCellData.cginc" sampler2D _MainTex; struct Input { float2 uv_MainTex; float visibility; }; … void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float4 cell0 = GetCellData(v, 0); float4 cell1 = GetCellData(v, 1); data.visibility = cell0.x * v.color.x + cell1.x * v.color.y; data.visibility = lerp(0.25, 1, data.visibility); } void surf (Input IN, inout SurfaceOutputStandard o) { float river = River(IN.uv_MainTex, _MainTex); fixed4 c = saturate(_Color + river); o.Albedo = c.rgb * IN.visibility; … } 


Rios com visibilidade.

unitypackage

Objetos e visibilidade


Agora a visibilidade funciona para todo o terreno gerado processualmente, mas até agora não afeta os recursos do terreno. Edifícios, fazendas e árvores são criados a partir de pré-fabricados, e não a partir de geometria processual, portanto, não podemos adicionar índices de células e misturar pesos com seus vértices. Como cada um desses objetos pertence a apenas uma célula, precisamos determinar em qual célula eles estão. Se pudermos fazer isso, teremos acesso aos dados das células correspondentes e aplicaremos visibilidade.

Já podemos transformar as posições XZ do mundo em índices de células. Essa transformação foi usada para editar terrenos e gerenciar esquadrões. No entanto, o código correspondente não é trivial. Ele usa operações inteiras e requer lógica para trabalhar com arestas. Isso é impraticável para um sombreador, para que possamos criar a maior parte da lógica em uma textura e usá-la.

Já estamos usando uma textura com um padrão hexagonal para projetar a grade sobre a topografia. Essa textura define uma área de célula de 2 × 2. Portanto, podemos calcular facilmente em que área estamos. Depois disso, você pode aplicar uma textura contendo deslocamentos X e Z para as células nessa área e usar esses dados para calcular a célula na qual estamos localizados.

Aqui está uma textura semelhante. O deslocamento X é armazenado em seu canal vermelho e o deslocamento Z é armazenado no canal verde. Como abrange a área de 2 × 2 células, precisamos de compensações de 0 e 2. Esses dados não podem ser armazenados no canal de cores, portanto as compensações são reduzidas pela metade. Como não precisamos de bordas claras das células, uma pequena textura é suficiente.


A textura das coordenadas da grade.

Adicione textura ao projeto. Defina o Modo Wrap para Repetir , assim como a outra textura de malha. Não precisamos de mixagem, portanto, para o Blend Mode , escolheremos Point . Desative também a compactação para que os dados não sejam distorcidos. Desative o modo sRGB para que, ao renderizar no modo linear, nenhuma conversão de espaço de cores seja executada. E, finalmente, não precisamos de texturas mip.


Opções de importação de textura.

Shader de Objetos com Visibilidade


Crie um novo sombreador de recursos para adicionar suporte de visibilidade aos objetos. Este é um shader de superfície simples com um programa de vértice. Adicione HexCellData a ele e passe o indicador de visibilidade ao programa de fragmento e, como de costume, considere-o em cores. A diferença aqui é que não podemos usá- GetCellDatalo porque os dados de malha necessários não existem. Em vez disso, temos uma posição no mundo. Mas, por enquanto, deixe a visibilidade igual a 1.

 Shader "Custom/Feature" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 [NoTilingOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.0 #include "../HexCellData.cginc" sampler2D _MainTex, _GridCoordinates; half _Glossiness; half _Metallic; fixed4 _Color; struct Input { float2 uv_MainTex; float visibility; }; void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float3 pos = mul(unity_ObjectToWorld, v.vertex); data.visibility = 1; } void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb * IN.visibility; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } ENDCG } FallBack "Diffuse" } 

Altere todos os materiais dos objetos para que eles usem o novo sombreador e atribua a eles a textura das coordenadas da grade.


Urbano com textura de malha.

Acessar dados da célula


Para provar a textura das coordenadas da grade no programa de vértices, precisamos novamente de tex2Dlodum vetor de coordenadas de textura de quatro componentes. As duas primeiras coordenadas são a posição do mundo XZ. Os outros dois são iguais a zero como antes.

  void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); float3 pos = mul(unity_ObjectToWorld, v.vertex); float4 gridUV = float4(pos.xz, 0, 0); data.visibility = 1; } 

Como no sombreador Terrain , esticamos as coordenadas UV para que a textura tenha a proporção correta correspondente à grade de hexágonos.

  float4 gridUV = float4(pos.xz, 0, 0); gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); 

Podemos descobrir em que parte das células 2 × 2 estamos tomando o valor das coordenadas UV arredondadas para baixo. Isso forma a base para as coordenadas das células.

  float4 gridUV = float4(pos.xz, 0, 0); gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); float2 cellDataCoordinates = floor(gridUV.xy); 

Para encontrar as coordenadas da célula em que estamos, adicionamos os deslocamentos armazenados na textura.

  float2 cellDataCoordinates = floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg; 

Como parte da grade tem tamanho 2 × 2 e as compensações são reduzidas pela metade, precisamos dobrar o resultado para obter as coordenadas finais.

  float2 cellDataCoordinates = floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg; cellDataCoordinates *= 2; 

Agora, temos as coordenadas XZ da grade de células que precisamos converter nas coordenadas UV dessas células. Isso pode ser feito simplesmente movendo-se para os centros dos pixels e dividindo-os em tamanhos de textura. Então, vamos adicionar uma função para isso no arquivo de inclusão HexCellData que também manipula a amostragem.

 float4 GetCellData (float2 cellDataCoordinates) { float2 uv = cellDataCoordinates + 0.5; uv.x *= _HexCellData_TexelSize.x; uv.y *= _HexCellData_TexelSize.y; return tex2Dlod(_HexCellData, float4(uv, 0, 0)); } 

Agora podemos usar isso no programa de vertex shader o recurso .

  cellDataCoordinates *= 2; data.visibility = GetCellData(cellDataCoordinates).x; data.visibility = lerp(0.25, 1, data.visibility); 


Objetos com visibilidade.

Finalmente, a visibilidade afeta o mapa inteiro, com exceção das unidades sempre visíveis. Como determinamos a visibilidade dos objetos para cada vértice e, em seguida, para o objeto que cruza o limite da célula, a visibilidade das células que ele fecha será mista. Mas os objetos são tão pequenos que permanecem constantemente dentro de suas células, mesmo levando em consideração a distorção de posições. No entanto, alguns podem fazer parte dos vértices em outra célula. Portanto, nossa abordagem é barata, mas imperfeita. Isso é mais perceptível no caso de paredes, cuja visibilidade varia entre as visibilidades das células vizinhas.


Paredes com visibilidade variável.

Como os segmentos de parede são gerados proceduralmente, podemos adicionar dados de célula à sua malha e usar a abordagem que usamos para o alívio. Infelizmente, as torres são pré-fabricadas, portanto ainda teremos inconsistências. Em termos gerais, a abordagem existente parece boa o suficiente para a geometria simples que usamos. No futuro, consideraremos modelos e paredes mais detalhados, portanto, melhoraremos o método de misturar sua visibilidade.

unitypackage

Parte 21: pesquisa de mapas


  • Exibimos tudo durante a edição.
  • Nós rastreamos as células investigadas.
  • Escondemos o que ainda é desconhecido.
  • Forçamos as unidades a evitar áreas inexploradas.

Na parte anterior, adicionamos o nevoeiro da guerra, que agora iremos refinar para implementar a pesquisa de mapas.


Estamos prontos para explorar o mundo.

Exibir o mapa inteiro no modo de edição


O significado do estudo é que, até que as células não sejam vistas, sejam consideradas desconhecidas e, portanto, invisíveis. Eles não devem ser obscurecidos, mas nem exibidos. Portanto, antes de adicionar suporte à pesquisa, habilitaremos a visibilidade no modo de edição.

Troca de visibilidade


Podemos controlar se os sombreadores usam visibilidade usando a palavra-chave, como foi feito com a sobreposição na grade. Vamos usar a palavra-chave HEX_MAP_EDIT_MODE para indicar o estado do modo de edição. Como vários shaders devem conhecer essa palavra-chave, nós a definiremos globalmente usando métodos estáticos Shader.EnableKeyWorde Shader.DisableKeyword. Iremos chamar o método apropriado HexGameUI.SetEditModeao alterar o modo de edição.

  public void SetEditMode (bool toggle) { enabled = !toggle; grid.ShowUI(!toggle); grid.ClearPath(); if (toggle) { Shader.EnableKeyword("HEX_MAP_EDIT_MODE"); } else { Shader.DisableKeyword("HEX_MAP_EDIT_MODE"); } } 

Shaders do modo de edição


Quando HEX_MAP_EDIT_MODE é definido, os sombreadores ignoram a visibilidade. Isso se resume ao fato de que a visibilidade da célula sempre será considerada igual a 1. Vamos adicionar uma função para filtrar os dados das células, dependendo da palavra - chave no início do arquivo de inclusão HexCellData .

 sampler2D _HexCellData; float4 _HexCellData_TexelSize; float4 FilterCellData (float4 data) { #if defined(HEX_MAP_EDIT_MODE) data.x = 1; #endif return data; } 

Passamos por essa função o resultado de ambas as funções GetCellDataantes de devolvê-la.

 float4 GetCellData (appdata_full v, int index) { … return FilterCellData(data); } float4 GetCellData (float2 cellDataCoordinates) { … return FilterCellData(tex2Dlod(_HexCellData, float4(uv, 0, 0))); } 

Para que tudo funcione, todos os sombreadores relevantes devem receber a diretiva multi_compile para criar opções, caso a palavra-chave HEX_MAP_EDIT_MODE seja definida. Adicione a linha apropriada aos sombreadores Estuário , Recurso , Rio , Estrada , Terreno , Água e Costa da Água , entre a diretiva de destino e a primeira diretiva de inclusão.

  #pragma multi_compile _ HEX_MAP_EDIT_MODE 

Agora, ao mudar para o modo de edição de mapas, o nevoeiro da guerra desaparecerá.

unitypackage

Pesquisa celular


Por padrão, as células devem ser consideradas inexploradas. Eles são explorados quando um esquadrão os vê. Depois disso, eles continuam sendo investigados se um destacamento puder vê-los.

Status do estudo de rastreamento


Para adicionar suporte para monitorar o status dos estudos, adicionamos à HexCellpropriedade geral IsExplored.

  public bool IsExplored { get; set; } 

O estado do estudo é determinado pela própria célula. Portanto, essa propriedade deve ser configurada apenas HexCell. Para adicionar essa restrição, definiremos o setter como privado.

  public bool IsExplored { get; private set; } 

A primeira vez que a visibilidade da célula se torna maior que zero, a célula começa a ser considerada investigada e, portanto, IsExploredum valor deve ser atribuído true. De fato, basta que marquemos a célula como examinada quando a visibilidade aumentar para 1. Isso deve ser feito antes da chamada RefreshVisibility.

  public void IncreaseVisibility () { visibility += 1; if (visibility == 1) { IsExplored = true; ShaderData.RefreshVisibility(this); } } 

Transferindo o estado da pesquisa para shaders


Como no caso da visibilidade das células, transferimos seu estado de pesquisa para os sombreadores através dos dados do sombreador. No final, é apenas outro tipo de visibilidade. HexCellShaderData.RefreshVisibilityarmazena o estado de visibilidade no canal de dados R. Vamos manter o estado do estudo nos dados do canal G.

  public void RefreshVisibility (HexCell cell) { int index = cell.Index; cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0; cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0; enabled = true; } 

Alívio inexplorado preto


Agora podemos usar shaders para visualizar o estado da pesquisa celular. Para garantir que tudo funcione como deveria, apenas tornamos o terreno inexplorado preto. Mas primeiro, para fazer o modo de edição funcionar, altere-o FilterCellDatapara filtrar os dados da pesquisa.

 float4 FilterCellData (float4 data) { #if defined(HEX_MAP_EDIT_MODE) data.xy = 1; #endif return data; } 

O sombreador Terrain passa os dados de visibilidade das três células possíveis para o programa de fragmentos. No caso do estado de pesquisa, nós os combinamos no programa de vértices e transferimos o único valor para o programa de fragmentos. Adicione o visibilityquarto componente à entrada para que tenhamos um lugar para isso.

  struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility; }; 

Agora, no programa de vértices, quando alteramos o índice de visibilidade, devemos acessar explicitamente data.visibility.xyz.

  void vert (inout appdata_full v, out Input data) { … data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz); } 

Depois disso, combinamos os estados do estudo e escrevemos o resultado em data.visibility.w. Isso é semelhante à combinação de visibilidade em outros shaders, mas usando o componente Y dessas células.

  data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz); data.visibility.w = cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z; 

O status da pesquisa está agora disponível no programa de fragmentos IN.visibility.w. Considere-o no cálculo de albedo.

  void surf (Input IN, inout SurfaceOutputStandard o) { … float explored = IN.visibility.w; o.Albedo = c.rgb * grid * _Color * explored; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


A topografia inexplorada agora é preta.

O alívio de células inexploradas agora tem uma cor preta. Mas isso ainda não afetou objetos, estradas e água. No entanto, isso é suficiente para garantir que o estudo funcione.

Salvando e carregando o status da pesquisa


Agora que adicionamos suporte à pesquisa, precisamos garantir que o status da pesquisa seja levado em consideração ao salvar e carregar mapas. Portanto, precisamos aumentar a versão dos arquivos de mapa para 3. Para tornar essas alterações mais convenientes, vamos adicionar uma SaveLoadMenuconstante para isso .

  const int mapFileVersion = 3; 

Usaremos essa constante ao gravar a versão do arquivo Savee ao verificar o suporte ao arquivo Load.

  void Save (string path) { using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(mapFileVersion); hexGrid.Save(writer); } } void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header <= mapFileVersion) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

Como etapa final, HexCell.Saveregistramos o status do estudo.

  public void Save (BinaryWriter writer) { … writer.Write(IsExplored); } 

E vamos ler no final Load. Depois disso, ligaremos RefreshVisibilitycaso o estado do estudo seja diferente do anterior.

  public void Load (BinaryReader reader) { … IsExplored = reader.ReadBoolean(); ShaderData.RefreshVisibility(this); } 

Para manter a compatibilidade com versões anteriores dos arquivos salvos antigos, precisamos pular a leitura do estado salvo se a versão do arquivo for menor que 3. Nesse caso, por padrão, as células terão o estado "inexplorado". Para fazer isso, precisamos adicionar Loaddados do cabeçalho como parâmetro .

  public void Load (BinaryReader reader, int header) { … IsExplored = header >= 3 ? reader.ReadBoolean() : false; ShaderData.RefreshVisibility(this); } 

Agora HexGrid.Loadterá que passar os HexCell.Loaddados do cabeçalho.

  public void Load (BinaryReader reader, int header) { … for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader, header); } … } 

Agora, ao salvar e carregar mapas, o estado de exploração das células será levado em consideração.

unitypackage

Ocultar células desconhecidas


No estágio atual, as células inexploradas são visualmente indicadas por um relevo preto. Mas, na realidade, queremos que essas células sejam invisíveis porque são desconhecidas. Podemos tornar a geometria opaca transparente para que não fique visível. No entanto, a estrutura do shader de superfície Unity foi desenvolvida sem essa possibilidade em mente. Em vez de usar a transparência verdadeira, alteraremos os shaders para corresponder ao plano de fundo, o que também os tornará invisíveis.

Tornando o alívio realmente preto


Embora o relevo estudado seja preto, ainda podemos reconhecê-lo porque ele ainda possui iluminação especular. Para se livrar da iluminação, precisamos torná-la perfeitamente preta fosca. Para não afetar outras propriedades da superfície, é mais fácil alterar a cor especular para preto. Isso é possível se você usar um shader de superfície que funcione com especular, mas agora usamos o metálico padrão. Então, vamos começar mudando o shader Terrain para especular.

Substitua a propriedade de cor _Metallic na propriedade _Specular . Por padrão, seu valor de cor deve ser igual a (0,2, 0,2, 0,2). Por isso, garantimos que ele corresponderá à aparência da versão metálica.

  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 // _Metallic ("Metallic", Range(0,1)) = 0.0 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) } 

Altere também as variáveis ​​correspondentes do shader. A cor dos shaders de superfície especular é definida como fixed3, então vamos usá-lo.

  half _Glossiness; // half _Metallic; fixed3 _Specular; fixed4 _Color; 

Altere a superfície do pragma surf de Standard para StandardSpecular . Isso forçará o Unity a gerar shaders usando especular.

  #pragma surface surf StandardSpecular fullforwardshadows vertex:vert 

Agora a função surfprecisa do segundo parâmetro para ser do tipo SurfaceOutputStandardSpecular. Além disso, agora você precisa atribuir o valor não o.Metallic, mas o.Specular.

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … float explored = IN.visibility.w; o.Albedo = c.rgb * grid * _Color * explored; // o.Metallic = _Metallic; o.Specular = _Specular; o.Smoothness = _Glossiness; o.Alpha = ca; } 

Agora podemos obscurecer os destaques considerando a exploredcor especular.

  o.Specular = _Specular * explored; 


Terreno inexplorado sem iluminação refletida.

Como você pode ver na foto, agora o alívio inexplorado parece preto opaco. No entanto, quando vistas em um ângulo tangente, as superfícies se transformam em um espelho, pelo que o relevo começa a refletir o ambiente, ou seja, a caixa do céu.

Por que as superfícies se tornam espelhos?
. . Rendering .


Áreas inexploradas ainda refletem o meio ambiente.

Para se livrar dessas reflexões, consideraremos o alívio inexplorado completamente sombreado. Isso é feito atribuindo um valor ao exploredparâmetro de oclusão, que usamos como máscara de reflexão.

  float explored = IN.visibility.w; o.Albedo = c.rgb * grid * _Color * explored; o.Specular = _Specular * explored; o.Smoothness = _Glossiness; o.Occlusion = explored; o.Alpha = ca; 


Inexplorado sem reflexões.

Correspondência de fundo


Agora que o terreno inexplorado ignora toda a iluminação, é necessário ajustá-lo ao fundo. Como nossa câmera sempre olha de cima, o fundo é sempre cinza. Para informar ao sombreador de Terreno qual cor usar, adicione a propriedade _BackgroundColor , cujo padrão é preto.

  Properties { … _BackgroundColor ("Background Color", Color) = (0,0,0) } … half _Glossiness; fixed3 _Specular; fixed4 _Color; half3 _BackgroundColor; 

Para usar essa cor, vamos adicioná-la como luz emissiva. Isso é o.Emissionfeito atribuindo um valor de cor de fundo multiplicado por um menos explorado.

  o.Occlusion = explored; o.Emission = _BackgroundColor * (1 - explored); 

Como usamos a skybox padrão, a cor visível do plano de fundo não é a mesma. Em geral, um cinza levemente avermelhado seria a melhor cor. Ao configurar o material de alívio, você pode usar o código 68615BFF para Hex Color .


Material de alívio com a cor de fundo cinza.

Em geral, isso funciona, embora se você souber onde procurar, notará silhuetas muito fracas. Para que o player não possa vê-los, você pode atribuir uma cor de plano de fundo uniforme de 68615BFF à câmera em vez de skybox.


Câmera com uma cor de fundo uniforme.

Por que não remover o skybox?
, , environmental lighting . , .

Agora não conseguimos encontrar a diferença entre o fundo e as células inexploradas. Uma alta topografia inexplorada ainda pode obscurecer uma topografia baixa explorada em ângulos baixos da câmera. Além disso, partes inexploradas ainda projetam sombras sobre o explorado. Mas essas pistas mínimas podem ser negligenciadas.


Células inexploradas não são mais visíveis.

E se você não usar uma cor de fundo uniforme?
, , . . , . , , , UV- .

Ocultar objetos de relevo


Agora temos apenas a malha do alívio oculta. O restante do estado do estudo ainda não foi afetado.


Até agora, apenas o alívio está oculto.

Vamos alterar o sombreador de recursos , que é um sombreamento opaco como o Terrain . Transforme-o em um shader especular e adicione a cor de fundo. Vamos começar com as propriedades.

  Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 // _Metallic ("Metallic", Range(0,1)) = 0.0 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [NoScaleOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {} } 

Superfície pragma adicional e variáveis, como antes.

  #pragma surface surf StandardSpecular fullforwardshadows vertex:vert … half _Glossiness; // half _Metallic; fixed3 _Specular; fixed4 _Color; half3 _BackgroundColor; 

visibilitytambém é necessário mais um componente. Como o Feature combina a visibilidade de cada vértice, ele precisava apenas de um valor flutuante. Agora precisamos de dois.

  struct Input { float2 uv_MainTex; float2 visibility; }; 

Altere-o vertpara que ele use explicitamente os dados de visibilidade data.visibility.xe atribua o data.visibility.yvalor dos dados do estudo.

  void vert (inout appdata_full v, out Input data) { … float4 cellData = GetCellData(cellDataCoordinates); data.visibility.x = cellData.x; data.visibility.x = lerp(0.25, 1, data.visibility.x); data.visibility.y = cellData.y; } 

Altere-o surfpara que ele use os novos dados, como o Terrain .

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; float explored = IN.visibility.y; o.Albedo = c.rgb * (IN.visibility.x * explored); // o.Metallic = _Metallic; o.Specular = _Specular * explored; o.Smoothness = _Glossiness; o.Occlusion = explored; o.Emission = _BackgroundColor * (1 - explored); o.Alpha = ca; } 


Objetos de alívio ocultos.

Esconder a água


Em seguida estão os shaders Water e Water Shore . Vamos começar convertendo-os em shaders especulares. No entanto, eles não precisam de uma cor de fundo porque são shaders transparentes.

Após a conversão, adicione visibilitymais um componente e altere-o de acordo vert. Ambos os sombreadores combinam dados de três células.

  struct Input { … float2 visibility; }; … void vert (inout appdata_full v, out Input data) { … data.visibility.x = cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z; data.visibility.x = lerp(0.25, 1, data.visibility.x); data.visibility.y = cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z; } 

Water e Water Shore realizam surfoperações diferentes, mas definem suas propriedades de superfície da mesma maneira. Como são transparentes, levaremos em consideração exploreo canal alfa e não definiremos a emissão.

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … float explored = IN.visibility.y; o.Albedo = c.rgb * IN.visibility.x; o.Specular = _Specular * explored; o.Smoothness = _Glossiness; o.Occlusion = explored; o.Alpha = ca * explored; } 


Água escondida.

Escondemos estuários, rios e estradas


Ainda temos os shaders para estuário , rio e estrada . Todos os três são transparentes e combinam os dados de duas células. Alterne todos para especular e adicione-os aos visibilitydados da pesquisa.

  struct Input { … float2 visibility; }; … void vert (inout appdata_full v, out Input data) { … data.visibility.x = cell0.x * v.color.x + cell1.x * v.color.y; data.visibility.x = lerp(0.25, 1, data.visibility.x); data.visibility.y = cell0.y * v.color.x + cell1.y * v.color.y; } 

Altere a função dos surfsombreadores Estuário e Rio para que ele use os novos dados. Ambos precisam fazer as mesmas alterações.

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … float explored = IN.visibility.y; fixed4 c = saturate(_Color + water); o.Albedo = c.rgb * IN.visibility.x; o.Specular = _Specular * explored; o.Smoothness = _Glossiness; o.Occlusion = explored; o.Alpha = ca * explored; } 

A Shader Road é um pouco diferente porque usa uma métrica de mistura extra.

  void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025); fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility.x); float blend = IN.uv_MainTex.x; blend *= noise.x + 0.5; blend = smoothstep(0.4, 0.7, blend); float explored = IN.visibility.y; o.Albedo = c.rgb; o.Specular = _Specular * explored; o.Smoothness = _Glossiness; o.Occlusion = explored; o.Alpha = blend * explored; } 


Tudo está escondido.

unitypackage

Evitando células inexploradas


Embora tudo o que é desconhecido seja visualmente oculto, o estado do estudo não é levado em consideração ao procurar um caminho. Como resultado, é possível ordenar que as unidades se movam através e através de células inexploradas, determinando magicamente o caminho a seguir. Precisamos forçar as unidades a evitar células inexploradas.


Navegue pelas células inexploradas.

Esquadrões determinam o custo da mudança


Antes de abordar células inexploradas, vamos refazer o código para transferir o custo da mudança de HexGridpara HexUnit. Isso simplificará o suporte para unidades com diferentes regras de movimento.

Adicione ao HexUnitmétodo geral GetMoveCostpara determinar o custo da mudança. Ele precisa saber quais células estão se movendo entre elas, bem como a direção. Copiamos o código correspondente para os custos de mudança HexGrid.Searchpara esse método e alteramos os nomes das variáveis.

  public int GetMoveCost ( HexCell fromCell, HexCell toCell, HexDirection direction) { HexEdgeType edgeType = fromCell.GetEdgeType(toCell); if (edgeType == HexEdgeType.Cliff) { continue; } int moveCost; if (fromCell.HasRoadThroughEdge(direction)) { moveCost = 1; } else if (fromCell.Walled != toCell.Walled) { continue; } else { moveCost = edgeType == HexEdgeType.Flat ? 5 : 10; moveCost += toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel; } } 

O método deve retornar o custo da mudança. Usei o código antigo para pular movimentos inválidos continue, mas essa abordagem não funcionará aqui. Se o movimento não for possível, retornaremos os custos negativos do movimento.

  public int GetMoveCost ( HexCell fromCell, HexCell toCell, HexDirection direction) { HexEdgeType edgeType = fromCell.GetEdgeType(toCell); if (edgeType == HexEdgeType.Cliff) { return -1; } int moveCost; if (fromCell.HasRoadThroughEdge(direction)) { moveCost = 1; } else if (fromCell.Walled != toCell.Walled) { return -1; } else { moveCost = edgeType == HexEdgeType.Flat ? 5 : 10; moveCost += toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel; } return moveCost; } 

Agora precisamos saber ao encontrar o caminho, não apenas a velocidade, mas também a unidade selecionada. Mude de acordo HexGameUI.DoPathFinding.

  void DoPathfinding () { if (UpdateCurrentCell()) { if (currentCell && selectedUnit.IsValidDestination(currentCell)) { grid.FindPath(selectedUnit.Location, currentCell, selectedUnit); } else { grid.ClearPath(); } } } 

Como ainda precisamos acessar a velocidade do esquadrão, adicionaremos à HexUnitpropriedade Speed. Enquanto isso retornará um valor constante de 24.

  public int Speed { get { return 24; } } 

Em HexGridmudança, FindPathe Searchpara que eles possam trabalhar com nossa nova abordagem.

  public void FindPath (HexCell fromCell, HexCell toCell, HexUnit unit) { ClearPath(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, unit); ShowPath(unit.Speed); } bool Search (HexCell fromCell, HexCell toCell, HexUnit unit) { int speed = unit.Speed; … } 

Agora vamos remover do Searchcódigo antigo que determinava se é possível passar para a próxima célula e quais são os custos da mudança. Em vez disso, chamaremos HexUnit.IsValidDestinatione HexUnit.GetMoveCost. Iremos pular a célula se o custo da mudança for negativo.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } // if (neighbor.IsUnderwater || neighbor.Unit) { // continue; // } // HexEdgeType edgeType = current.GetEdgeType(neighbor); // if (edgeType == HexEdgeType.Cliff) { // continue; // } // int moveCost; // if (current.HasRoadThroughEdge(d)) { // moveCost = 1; // } // else if (current.Walled != neighbor.Walled) { // continue; // } // else { // moveCost = edgeType == HexEdgeType.Flat ? 5 : 10; // moveCost += neighbor.UrbanLevel + neighbor.FarmLevel + // neighbor.PlantLevel; // } if (!unit.IsValidDestination(neighbor)) { continue; } int moveCost = unit.GetMoveCost(current, neighbor, d); if (moveCost < 0) { continue; } int distance = current.Distance + moveCost; int turn = (distance - 1) / speed; if (turn > currentTurn) { distance = turn * speed + moveCost; } … } 

Ignorar áreas inexploradas


Para evitar células inexploradas, basta garantir que HexUnit.IsValidDestinationverifiquemos se a célula é examinada.

  public bool IsValidDestination (HexCell cell) { return cell.IsExplored && !cell.IsUnderwater && !cell.Unit; } 


Mais unidades não poderão acessar células inexploradas.

Como as células inexploradas não são mais pontos finais válidos, os esquadrões os evitarão ao passar para o ponto final. Ou seja, áreas inexploradas agem como barreiras que prolongam o caminho ou mesmo o tornam impossível. Teremos que aproximar as unidades de um terreno desconhecido para primeiro explorar a área.

E se um caminho mais curto aparecer durante a mudança?
. , . .

, , . , .

unitypackage

Parte 22: Visibilidade Aprimorada


  • Altere suavemente a visibilidade.
  • Use a altura da célula para determinar o escopo.
  • Oculte a borda do mapa.

Ao adicionar suporte à exploração de mapas, melhoraremos os cálculos e transições do escopo.


Para ver mais, suba mais alto.

Transições de visibilidade


A célula é visível ou invisível, porque está no escopo do desapego ou não. Mesmo que pareça que a unidade precise de algum tempo para se mover entre as células, seu campo de visão salta de célula em célula instantaneamente. Como resultado, a visibilidade das células circundantes muda drasticamente. O movimento da equipe parece suave, mas as mudanças na visibilidade são repentinas.

Idealmente, a visibilidade também deve mudar sem problemas. Uma vez no campo de visibilidade, as células devem ser iluminadas gradualmente e, deixando-as, gradualmente escurecer. Ou talvez você prefira transições instantâneas? Vamos adicionar à HexCellShaderDatapropriedade que alterna transições instantâneas. Por padrão, as transições serão suaves.

  public bool ImmediateMode { get; set; } 

Rastreamento de células de transição


Mesmo ao exibir transições suaves, os verdadeiros dados de visibilidade ainda permanecem binários, ou seja, o efeito é apenas visual. Isso significa que as transições de visibilidade devem ser tratadas HexCellShaderData. Vamos fornecer uma lista de células nas quais a transição é realizada. Certifique-se de que, a cada inicialização, esteja vazio.

 using System.Collections.Generic; using UnityEngine; public class HexCellShaderData : MonoBehaviour { Texture2D cellTexture; Color32[] cellTextureData; List<HexCell> transitioningCells = new List<HexCell>(); public bool ImmediateMode { get; set; } public void Initialize (int x, int z) { … transitioningCells.Clear(); enabled = true; } … } 

No momento, estamos definindo os dados da célula RefreshVisibilitydiretamente. Isso ainda está correto para o modo de transição instantânea, mas quando está desabilitado, devemos adicionar uma célula à lista de células de transição.

  public void RefreshVisibility (HexCell cell) { int index = cell.Index; if (ImmediateMode) { cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0; cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0; } else { transitioningCells.Add(cell); } enabled = true; } 

A visibilidade parece não funcionar mais, porque, por enquanto, não estamos fazendo nada com as células da lista.

Loop através de células em um loop


Em vez de definir instantaneamente os valores correspondentes para 255 ou 0, aumentaremos / diminuiremos esses valores gradualmente. A suavidade da transição depende da taxa de mudança. Não deve ser muito rápido e nem muito lento. Um bom compromisso entre transições bonitas e a conveniência do jogo é mudar dentro de um segundo. Vamos definir uma constante para facilitar a alteração.

  const float transitionSpeed = 255f; 

Agora LateUpdate, podemos definir o delta aplicado aos valores. Para fazer isso, multiplique o delta do tempo pela velocidade. Deve ser um número inteiro, porque não sabemos quão grande pode ser. Uma queda acentuada na taxa de quadros pode tornar o delta maior que 255.

Além disso, precisamos atualizar enquanto houver células de transição. Portanto, o código deve ser incluído enquanto houver algo na lista.

  void LateUpdate () { int delta = (int)(Time.deltaTime * transitionSpeed); cellTexture.SetPixels32(cellTextureData); cellTexture.Apply(); enabled = transitioningCells.Count > 0; } 

Também teoricamente possível taxas de quadros muito altas. Em combinação com uma baixa velocidade de transição, isso pode nos dar um delta de 0. Para que a alteração ocorra, forçamos o delta mínimo a ser 1.

  int delta = (int)(Time.deltaTime * transitionSpeed); if (delta == 0) { delta = 1; } 

Após receber o delta, podemos percorrer todas as células de transição e atualizar seus dados. Suponha que tenhamos um método para isso UpdateCellData, cujos parâmetros são a célula e o delta correspondentes.

  int delta = (int)(Time.deltaTime * transitionSpeed); if (delta == 0) { delta = 1; } for (int i = 0; i < transitioningCells.Count; i++) { UpdateCellData(transitioningCells[i], delta); } 

Em algum momento, a transição celular deve ser concluída. Suponha que o método retorne informações sobre se a transição ainda está em andamento. Quando ele pára, podemos remover a célula da lista. Depois disso, devemos decrementar o iterador para não pular as células.

  for (int i = 0; i < transitioningCells.Count; i++) { if (!UpdateCellData(transitioningCells[i], delta)) { transitioningCells.RemoveAt(i--); } } 

A ordem na qual as células de transição são processadas não é importante. Portanto, não precisamos excluir a célula no índice atual, o que forçaria RemoveAttodas as células a se moverem após ela. Em vez disso, movemos a última célula para o índice atual e excluímos a última.

  if (!UpdateCellData(transitioningCells[i], delta)) { transitioningCells[i--] = transitioningCells[transitioningCells.Count - 1]; transitioningCells.RemoveAt(transitioningCells.Count - 1); } 

Agora temos que criar um método UpdateCellData. Para fazer seu trabalho, ele precisará de um índice e dados de célula, então vamos começar obtendo-os. Ele também deve determinar se deve continuar atualizando a célula. Por padrão, assumiremos que não é necessário. Após a conclusão do trabalho, é necessário aplicar os dados alterados e retornar o status "a atualização continua".

  bool UpdateCellData (HexCell cell, int delta) { int index = cell.Index; Color32 data = cellTextureData[index]; bool stillUpdating = false; cellTextureData[index] = data; return stillUpdating; } 

Atualizando dados da célula


Nesta fase, temos uma célula que está em processo de transição ou que já a concluiu. Primeiro, vamos verificar o status da sonda de célula. Se a célula for examinada, mas seu valor G ainda não for igual a 255, ela estará em processo de transição; portanto, monitoraremos isso.

  bool stillUpdating = false; if (cell.IsExplored && data.g < 255) { stillUpdating = true; } cellTextureData[index] = data; 

Para executar a transição, adicionaremos um delta ao valor G da célula. As operações aritméticas não funcionam com bytes, elas são primeiro convertidas em número inteiro. Portanto, a soma terá o formato inteiro, que deve ser convertido em byte.

  if (cell.IsExplored && data.g < 255) { stillUpdating = true; int t = data.g + delta; data.g = (byte)t; } 

Mas antes da conversão, você precisa garantir que o valor não exceda 255.

  int t = data.g + delta; data.g = t >= 255 ? (byte)255 : (byte)t; 

Em seguida, precisamos fazer o mesmo para a visibilidade, que usa o valor de R.

  if (cell.IsExplored && data.g < 255) { … } if (cell.IsVisible && data.r < 255) { stillUpdating = true; int t = data.r + delta; data.r = t >= 255 ? (byte)255 : (byte)t; } 

Como a célula pode se tornar invisível novamente, precisamos verificar se é necessário diminuir o valor de R. Isso acontece quando a célula está invisível, mas R é maior que zero.

  if (cell.IsVisible) { if (data.r < 255) { stillUpdating = true; int t = data.r + delta; data.r = t >= 255 ? (byte)255 : (byte)t; } } else if (data.r > 0) { stillUpdating = true; int t = data.r - delta; data.r = t < 0 ? (byte)0 : (byte)t; } 

Agora está UpdateCellDatapronto e as transições de visibilidade são executadas corretamente.


Transições de visibilidade.

Proteção contra elementos de transição duplicados


As transições funcionam, mas itens duplicados podem aparecer na lista. Isso acontece se o estado de visibilidade da célula mudar enquanto ela ainda está em transição. Por exemplo, quando a célula é visível durante o movimento do esquadrão apenas por um curto período de tempo.

Como resultado da aparência de elementos duplicados, a transição de célula é atualizada várias vezes por quadro, o que leva a transições mais rápidas e trabalho extra. Para evitar isso, verifique antes de adicionar uma célula se ela já está na lista. No entanto, uma pesquisa de lista em todas as chamadasRefreshVisibilitycaro, especialmente quando várias transições de células são realizadas. Em vez disso, vamos usar outro canal que ainda não foi usado para indicar se a célula está em processo de transição, por exemplo, o valor B. Ao adicionar uma célula à lista, atribuiremos o valor 255 e adicionaremos apenas as células cujo valor não seja igual a 255.

  public void RefreshVisibility (HexCell cell) { int index = cell.Index; if (ImmediateMode) { cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0; cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0; } else if (cellTextureData[index].b != 255) { cellTextureData[index].b = 255; transitioningCells.Add(cell); } enabled = true; } 

Para que isso funcione, precisamos redefinir o valor de B após a conclusão da transição celular.

  bool UpdateCellData (HexCell cell, int delta) { … if (!stillUpdating) { data.b = 0; } cellTextureData[index] = data; return stillUpdating; } 


Transições sem duplicatas.

Carregando instantaneamente a visibilidade


As alterações de visibilidade agora são sempre graduais, mesmo ao carregar um mapa. Isso é ilógico, porque o mapa descreve o estado em que as células já estão visíveis, portanto a transição é inadequada aqui. Além disso, a realização de transições para as muitas células visíveis de um mapa grande pode diminuir a velocidade do jogo após o carregamento. Portanto, antes de carregar células e esquadrões, vamos mudar HexGrid.Loadpara o modo de transição instantânea.

  public void Load (BinaryReader reader, int header) { … cellShaderData.ImmediateMode = true; for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader, header); } … } 

Portanto, redefinimos a configuração inicial do modo de transição instantânea, qualquer que seja. Talvez ele já esteja desligado ou tenha feito uma opção de configuração, para que lembremos do modo inicial e passemos a ele após a conclusão do trabalho.

  public void Load (BinaryReader reader, int header) { … bool originalImmediateMode = cellShaderData.ImmediateMode; cellShaderData.ImmediateMode = true; … cellShaderData.ImmediateMode = originalImmediateMode; } 

unitypackage

Escopo dependente da altura


Até agora, usamos um escopo constante de três para todas as unidades, mas, na realidade, é mais complicado. No caso geral, não podemos ver o objeto por duas razões: ou algum obstáculo nos impede de vê-lo ou o objeto é muito pequeno ou distante. Em nosso jogo, apenas implementamos a limitação de escopo.

Não podemos ver o que está do lado oposto da Terra, porque o planeta nos obscurece. Só podemos ver o horizonte. Como o planeta pode aproximadamente ser considerado uma esfera, quanto maior o ponto de vista, mais superfície podemos ver, ou seja, o horizonte depende da altura.


O horizonte depende da altura do ponto de vista.

A visibilidade limitada de nossas unidades imita o efeito do horizonte criado pela curvatura da Terra. O alcance de sua revisão depende do tamanho do planeta e da escala do mapa. Pelo menos essa é a explicação lógica. Mas a principal razão para reduzir o escopo é a jogabilidade, essa é uma limitação chamada névoa da guerra. No entanto, entendendo a física subjacente ao campo de visão, podemos concluir que um ponto de vista alto deve ter valor estratégico, porque afasta o horizonte e permite que você olhe para obstáculos mais baixos. Mas até agora não o implementamos.

Altura para revisão


Para levar em conta a altura ao determinar o escopo, precisamos saber a altura. Essa será a altura ou nível habitual da água, dependendo da célula terrestre ou da água. Vamos adicionar isso à HexCellpropriedade

  public int ViewElevation { get { return elevation >= waterLevel ? elevation : waterLevel; } } 

Mas se a altura afeta o escopo, com uma alteração na altura de visualização da célula, a situação de visibilidade também pode mudar. Como a célula bloqueou ou agora está bloqueando o escopo de várias unidades, não é tão fácil determinar o que precisa ser alterado. A célula em si não será capaz de resolver esse problema; portanto, informe uma alteração na situação HexCellShaderData. Suponha que você HexCellShaderDatatenha um método para isso ViewElevationChanged. Nós o chamaremos mediante atribuição HexCell.Elevation, se necessário.

  public int Elevation { get { return elevation; } set { if (elevation == value) { return; } int originalViewElevation = ViewElevation; elevation = value; if (ViewElevation != originalViewElevation) { ShaderData.ViewElevationChanged(); } … } } 

O mesmo vale para WaterLevel.

  public int WaterLevel { get { return waterLevel; } set { if (waterLevel == value) { return; } int originalViewElevation = ViewElevation; waterLevel = value; if (ViewElevation != originalViewElevation) { ShaderData.ViewElevationChanged(); } ValidateRivers(); Refresh(); } } 

Redefinir visibilidade


Agora precisamos criar um método HexCellShaderData.ViewElevationChanged. Determinar como uma situação geral de visibilidade muda é uma tarefa complexa, especialmente ao alterar várias células ao mesmo tempo. Portanto, não apresentaremos nenhum truque, mas simplesmente planejamos redefinir a visibilidade de todas as células. Adicione um campo booleano para controlar se deve ou não fazer isso. Dentro do método, vamos simplesmente configurá-lo para true e incluir o componente. Independentemente do número de células que foram alteradas simultaneamente, isso levará a uma única redefinição.

  bool needsVisibilityReset; … public void ViewElevationChanged () { needsVisibilityReset = true; enabled = true; } 

Para redefinir os valores de visibilidade de todas as células, você deve ter acesso a elas, que você HexCellShaderDatanão possui. Então, vamos delegar essa responsabilidade HexGrid. Para fazer isso, você precisa adicionar à HexCellShaderDatapropriedade, o que permite que você consulte a grade. Em seguida, podemos usá-lo LateUpdatepara solicitar uma redefinição.

  public HexGrid Grid { get; set; } … void LateUpdate () { if (needsVisibilityReset) { needsVisibilityReset = false; Grid.ResetVisibility(); } … } 

Vamos para HexGrid: defina o link para a grade HexGrid.Awakedepois de criar os dados do sombreador.

  void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; cellShaderData = gameObject.AddComponent<HexCellShaderData>(); cellShaderData.Grid = this; CreateMap(cellCountX, cellCountZ); } 

HexGridtambém deve obter um método ResetVisibilitypara descartar todas as células. Basta fazê-lo girar em torno de todas as células do loop e delegar a redefinição para si mesma.

  public void ResetVisibility () { for (int i = 0; i < cells.Length; i++) { cells[i].ResetVisibility(); } } 

Agora precisamos adicionar HexCellum método ResetVisibilty. Ele simplesmente zera a visibilidade e aciona a atualização de visibilidade. Isso deve ser feito quando a visibilidade da célula for maior que zero.

  public void ResetVisibility () { if (visibility > 0) { visibility = 0; ShaderData.RefreshVisibility(this); } } 

Após redefinir todos os dados de visibilidade, HexGrid.ResetVisibilityele deve aplicar novamente a visibilidade a todos os esquadrões, para os quais ele precisa conhecer o escopo de cada esquadrão. Suponha que você pode obtê-lo com a propriedade VisionRange.

  public void ResetVisibility () { for (int i = 0; i < cells.Length; i++) { cells[i].ResetVisibility(); } for (int i = 0; i < units.Count; i++) { HexUnit unit = units[i]; IncreaseVisibility(unit.Location, unit.VisionRange); } } 

Para que isso funcione, renomear refactor- HexUnit.visionRangeem HexUnit.VisionRangee transformá-lo em um recurso. Embora receba um valor constante de 3, mas no futuro mudará.

  public int VisionRange { get { return 3; } } 

Devido a isso, os dados de visibilidade serão redefinidos e permanecerão corretos após alterar a altura de visualização da célula. Mas é provável que alteremos as regras para determinar o escopo e executemos a recompilação no modo Reproduzir. Para que o escopo seja alterado independentemente, vamos redefinir HexGrid.OnEnablequando a recompilação for detectada.

  void OnEnable () { if (!HexMetrics.noiseSource) { … ResetVisibility(); } } 

Agora você pode alterar o código do escopo e ver os resultados, enquanto permanece no modo Reproduzir.

Expandindo o horizonte


O cálculo do escopo é determinado HexGrid.GetVisibleCells. Para que a altura afete o escopo, podemos simplesmente usar a altura de visualização fromCellredefinindo temporariamente a área transmitida. Para que possamos verificar facilmente se isso funciona.

  List<HexCell> GetVisibleCells (HexCell fromCell, int range) { … range = fromCell.ViewElevation; fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); … } 


Use a altura como um escopo.

Obstáculos à visibilidade


A aplicação de uma altura de visualização como um escopo somente funciona corretamente quando todas as outras células estão na altura zero. Mas se todas as células tiverem a mesma altura do ponto de vista, o campo de visão deverá ser zero. Além disso, células com alturas elevadas devem bloquear a visibilidade das células baixas atrás delas. Até agora, nada disso foi implementado.


O escopo não interfere.

A maneira mais correta de determinar o escopo seria verificar a emissão de raios, mas rapidamente se tornaria caro e ainda produziria resultados estranhos. Precisamos de uma solução rápida que crie resultados suficientemente bons que não precisem ser perfeitos. Além disso, é importante que as regras para determinar o escopo sejam simples, intuitivas e previsíveis para os jogadores.

Nossa solução será a seguinte: ao determinar a visibilidade de uma célula, adicionaremos a altura de visualização da célula vizinha à distância percorrida. De fato, isso reduz o escopo quando olhamos para essas células e, se forem ignoradas, isso não nos permitirá alcançar as células por trás delas.

  int distance = current.Distance + 1; if (distance + neighbor.ViewElevation > range) { continue; } 


Células altas bloqueiam a exibição.

Não deveríamos ver células altas à distância?
, , , . , .

Não olhe pelos cantos


Agora parece que as células altas bloqueiam a visualização para baixo, mas às vezes o escopo penetra através delas, embora pareça que isso não deva acontecer. Isso acontece porque o algoritmo de busca ainda encontra um caminho para essas células, ignorando as células bloqueadoras. Como resultado, parece que nossa área de visibilidade pode contornar obstáculos. Para evitar isso, precisamos garantir que apenas os caminhos mais curtos sejam levados em consideração ao determinar a visibilidade da célula. Isso pode ser feito descartando caminhos que se tornam mais longos que o necessário.

  HexCoordinates fromCoordinates = fromCell.coordinates; while (searchFrontier.Count > 0) { … for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int distance = current.Distance + 1; if (distance + neighbor.ViewElevation > range || distance > fromCoordinates.DistanceTo(neighbor.coordinates) ) { continue; } … } } 


Usamos apenas os caminhos mais curtos.

Por isso, corrigimos a maioria dos casos obviamente errados. Para células próximas, isso funciona bem, porque existem apenas caminhos mais curtos para elas. As células mais distantes têm mais opções de caminhos, portanto, a longas distâncias, um envelope de visibilidade ainda pode ocorrer. Isso não será um problema se as áreas de visibilidade permanecerem pequenas e as diferenças nas alturas adjacentes não forem muito grandes.

E, finalmente, em vez de substituir o campo de visão transmitido, adicionamos a ele a altura da visualização. O campo de visão do esquadrão indica sua altura, altitude de voo ou capacidade de reconhecimento.

  range += fromCell.ViewElevation; 


Vista com um campo de visão completo em um ponto de vista baixo.

Ou seja, as regras finais de visibilidade se aplicam à visão ao se mover pelo caminho mais curto até o campo de visão, levando em consideração a diferença na altura da célula em relação ao ponto de vista. Quando uma célula está fora do escopo, ela bloqueia todos os caminhos através dela. Como resultado, altos pontos de observação, dos quais nada impede a visão, tornam-se estrategicamente valiosos.

Que tal obstruir a visibilidade dos objetos?
, , . , , . .

unitypackage

Células que não podem ser exploradas


O último problema com a visibilidade diz respeito às bordas do mapa. O relevo abruptamente e sem transições termina, porque as células na borda não têm vizinhos.


Borda marcada do mapa.

Idealmente, a exibição visual de áreas e bordas inexploradas do mapa deve ser a mesma. Podemos conseguir isso adicionando casos especiais ao triangular arestas, quando elas não têm vizinhos, mas isso requer lógica adicional e teremos que trabalhar com células ausentes. Portanto, essa solução não é trivial. Uma abordagem alternativa é forçar as células de fronteira do mapa a serem inexploradas, mesmo que elas estejam no escopo do esquadrão. Essa abordagem é muito mais simples, então vamos usá-la. Também permite marcar como células inexploradas e outras, facilitando a criação de arestas desiguais do mapa. Além disso, as células ocultas nas bordas permitem criar estradas e rios que entram e saem do mapa do rio e da estrada, porque seus pontos finais estarão fora do escopo.Além disso, com a ajuda desta solução, você pode adicionar unidades entrando e saindo do mapa.

Marcamos as células como investigadas


Para indicar que uma célula pode ser investigado, aumentando a HexCellpropriedade Explorable.

  public bool Explorable { get; set; } 

Agora, uma célula pode estar visível se for investigada. Por isso IsVisible, alteraremos a propriedade para levar isso em consideração.

  public bool IsVisible { get { return visibility > 0 && Explorable; } } 

O mesmo se aplica a IsExplored. No entanto, para isso, investigamos a propriedade padrão. Precisamos convertê-lo em uma propriedade explícita para poder alterar a lógica de seu getter.

  public bool IsExplored { get { return explored && Explorable; } private set { explored = value; } } … bool explored; 

Ocultar a borda do mapa


Esconder borda retangular do cartão pode ser no método HexGrid.CreateCell. As células que não estão no limite são investigadas, todo o resto é inexplorado.

  void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); cell.Index = i; cell.ShaderData = cellShaderData; cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; … } 

Agora, as cartas estão escurecidas nas bordas, escondendo-se atrás deles, enormes espaços inexplorados. Como resultado, o tamanho da área estudada dos mapas diminui em cada dimensão em dois.


Borda inexplorada do mapa.

É possível tornar o estado da pesquisa editável?
, , . .

Células inexploradas impedem a visibilidade


Finalmente, se a célula não puder ser examinada, ela deverá interferir na visibilidade. Mude HexGrid.GetVisibleCellspara levar isso em consideração.

  if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase || !neighbor.Explorable ) { continue; } 

unitypackage

Parte 23: gerando terras


  • Preencha novos mapas com paisagens geradas.
  • Nós levantamos terra sobre a água, inundamos alguns.
  • Controlamos a quantidade de terra criada, sua altura e irregularidade.
  • Adicionamos suporte para várias opções de configuração para criar mapas variáveis.
  • Fazemos isso para que o mesmo mapa possa ser gerado novamente.

Esta parte do tutorial será o início de uma série sobre geração de mapas procedurais.

Esta parte foi criada no Unity 2017.1.0.


Um dos muitos mapas gerados.

Geração de cartão


Embora possamos criar qualquer mapa, leva muito tempo. Seria conveniente se o aplicativo pudesse ajudar o designer gerando cartões para ele, que ele poderá modificar a seu gosto. Você pode dar outro passo e se livrar completamente da criação manual do design, transferindo completamente a responsabilidade de gerar o mapa finalizado para o aplicativo. Devido a isso, o jogo pode ser jogado toda vez com um novo cartão e cada sessão do jogo será diferente. Para que tudo isso seja possível, precisamos criar um algoritmo de geração de mapas.

O tipo de algoritmo de geração necessário depende do tipo de cartão necessário. Não existe uma abordagem correta, você sempre precisa procurar um compromisso entre credibilidade e jogabilidade.

Para que uma carta seja crível, ela deve parecer bem possível e real para o jogador. Isso não significa que o mapa deva parecer uma parte do nosso planeta. Pode ser um planeta diferente ou uma realidade completamente diferente. Mas se deve indicar o alívio da Terra, deve pelo menos se assemelhar a ela.

A jogabilidade está relacionada à forma como as cartas correspondem à jogabilidade. Às vezes entra em conflito com a credibilidade. Por exemplo, embora as cadeias de montanhas possam parecer bonitas, ao mesmo tempo, limitam bastante o movimento e a visualização das unidades. Se isso for indesejável, você terá que ficar sem montanhas, o que reduzirá a credibilidade e limitará a expressividade do jogo. Ou podemos salvar as montanhas, mas reduzir seu impacto na jogabilidade, o que também pode reduzir a credibilidade.

Além disso, a viabilidade deve ser considerada. Por exemplo, você pode criar um planeta semelhante à Terra muito realista, simulando placas tectônicas, erosão, chuvas, erupções vulcânicas, os efeitos dos meteoritos e da lua e assim por diante. Mas o desenvolvimento de um sistema assim exigirá muito tempo. Além disso, pode levar muito tempo para gerar um planeta assim, e os jogadores não vão querer esperar alguns minutos antes de iniciar um novo jogo. Ou seja, a simulação é uma ferramenta poderosa, mas tem um preço.

Os jogos geralmente usam trade-offs entre credibilidade, jogabilidade e viabilidade. Às vezes, esses compromissos são invisíveis e parecem completamente normais, e às vezes parecem aleatórios, inconsistentes ou caóticos, dependendo das decisões tomadas durante o processo de desenvolvimento. Isso se aplica não apenas à geração de cartões, mas ao desenvolver um gerador de cartões procedurais, você precisa prestar atenção especial a isso. Você pode gastar muito tempo criando um algoritmo que gera cartões bonitos que acabam sendo inúteis para o jogo que você está criando.

Nesta série de tutoriais, criaremos um relevo semelhante à terra. Deve parecer interessante, com grande variabilidade e ausência de grandes áreas homogêneas. A escala de relevo será grande, os mapas cobrirão um ou mais continentes, regiões dos oceanos ou até um planeta inteiro. Precisamos de controle sobre a geografia, incluindo massas terrestres, clima, número de regiões e solavancos. Nesta parte, lançaremos as bases para a criação de sushi.

Introdução no modo de edição


Vamos nos concentrar no mapa, não na jogabilidade, por isso será mais conveniente iniciar o aplicativo no modo de edição. Graças a isso, podemos ver imediatamente os cartões. Portanto, mudaremos HexMapEditor.Awakedefinindo o modo de edição como true e ativando a palavra-chave shader desse modo.

  void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); Shader.EnableKeyword("HEX_MAP_EDIT_MODE"); SetEditMode(true); } 

Gerador de cartões


Como é necessário muito código para gerar mapas procedurais, não o adicionaremos diretamente a HexGrid. Em vez disso, criaremos um novo componente HexMapGeneratore HexGridnão o saberemos. Isso simplificará a transição para outro algoritmo, se necessário.

O gerador precisa de um link para a grade, portanto, adicionaremos um campo geral a ela. Além disso, adicionamos um método geral GenerateMapque lidará com o trabalho do algoritmo. Forneceremos as dimensões do mapa como parâmetros e forçá-lo-emos a ser usado para criar um novo mapa vazio.

 using System.Collections.Generic; using UnityEngine; public class HexMapGenerator : MonoBehaviour { public HexGrid grid; public void GenerateMap (int x, int z) { grid.CreateMap(x, z); } } 

Adicione um objeto com um componente à cena HexMapGeneratore conecte-o à grade.


Objeto gerador de mapa.

Alterar o menu de um novo mapa


Vamos alterá-lo NewMapMenupara que ele possa gerar cartões, e não apenas criar cartões vazios. Controlaremos sua funcionalidade através de um campo booleano generateMaps, que por padrão possui um valor true. Vamos criar um método geral para definir esse campo, como fizemos para alternar opções HexMapEditor. Adicione a opção apropriada ao menu e conecte-a ao método.

  bool generateMaps = true; public void ToggleMapGeneration (bool toggle) { generateMaps = toggle; } 


Menu de um novo cartão com um interruptor.

Dê ao menu um link para o gerador de mapas. Então forçaremos a chamada do método GenerateMapgerador , se necessário , e não apenas executaremos a CreateMapgrade.

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


Conexão ao gerador.

Acesso celular


Para que o gerador funcione, ele precisa acessar as células. Nós HexGridjá temos métodos comuns GetCellque requerem ou vetor posição, ou coordenadas hexágono. O gerador não precisa trabalhar com um ou outro, portanto, adicionamos dois métodos convenientes HexGrid.GetCellque funcionarão com as coordenadas do deslocamento ou índice da célula.

  public HexCell GetCell (int xOffset, int zOffset) { return cells[xOffset + zOffset * cellCountX]; } public HexCell GetCell (int cellIndex) { return cells[cellIndex]; } 

Agora ele HexMapGeneratorpode receber células diretamente. Por exemplo, depois de criar um novo mapa, ele pode usar coordenadas de grama para definir grama como o alívio da coluna do meio das células.

  public void GenerateMap (int x, int z) { grid.CreateMap(x, z); for (int i = 0; i < z; i++) { grid.GetCell(x / 2, i).TerrainTypeIndex = 1; } } 


Coluna de grama em um pequeno mapa.

unitypackage

Fazendo sushi


Ao gerar um mapa, começamos completamente sem terra. Pode-se imaginar que o mundo inteiro seja inundado por um oceano imenso. Uma terra é criada quando parte do fundo do oceano é empurrada tanto que sobe acima da água. Precisamos decidir quanta terra deve ser criada dessa maneira, onde ela aparecerá e que forma terá.

Aumente o alívio


Vamos começar pequenos - levante um pedaço de terra acima da água. Criamos para isso um método RaiseTerraincom um parâmetro para controlar o tamanho do gráfico. Chame esse método GenerateMap, substituindo o código de teste anterior. Vamos começar com um pequeno pedaço de terra composto por sete células.

  public void GenerateMap (int x, int z) { grid.CreateMap(x, z); // for (int i = 0; i < z; i++) { // grid.GetCell(x / 2, i).TerrainTypeIndex = 1; // } RaiseTerrain(7); } void RaiseTerrain (int chunkSize) {} 

Até o momento, usamos o tipo de relevo "grama" para indicar a terra elevada, e o relevo original "areia" refere-se ao oceano. Faça-nos RaiseTerrainpegar uma célula aleatória e alterar o tipo de relevo até obtermos a quantidade certa de terra.

Para obter uma célula aleatória, adicionamos um método GetRandomCellque determina um índice de célula aleatória e obtém a célula correspondente da grade.

  void RaiseTerrain (int chunkSize) { for (int i = 0; i < chunkSize; i++) { GetRandomCell().TerrainTypeIndex = 1; } } HexCell GetRandomCell () { return grid.GetCell(Random.Range(0, grid.cellCountX * grid.cellCountZ)); } 


Sete células aleatórias de sushi.

Como no final, podemos precisar de muitas células aleatórias ou percorrer todas as células várias vezes, vamos acompanhar o número de células na própria célula HexMapGenerator.

  int cellCount; public void GenerateMap (int x, int z) { cellCount = x * z; … } … HexCell GetRandomCell () { return grid.GetCell(Random.Range(0, cellCount)); } 

Criação de um site


Até agora, estamos transformando sete células aleatórias em terra, e elas podem estar em qualquer lugar. Muito provavelmente eles não formam uma única área de terra. Além disso, podemos selecionar as mesmas células várias vezes, para obter menos terra. Para resolver os dois problemas, sem restrições, selecionaremos apenas a primeira célula. Depois disso, devemos selecionar apenas as células próximas às selecionadas anteriormente. Essas restrições são semelhantes às limitações da pesquisa de caminho, portanto, usamos a mesma abordagem aqui.

Adicionamos HexMapGeneratornossa própria propriedade e o contador da fase da borda de pesquisa, como estava HexGrid.

  HexCellPriorityQueue searchFrontier; int searchFrontierPhase; 

Verifique se a fila de prioridade existe antes de precisarmos.

  public void GenerateMap (int x, int z) { cellCount = x * z; grid.CreateMap(x, z); if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } RaiseTerrain(7); } 

Depois de criar um novo mapa, o limite de pesquisa para todas as células é zero. Mas se formos procurar células no processo de geração de mapas, aumentaremos sua borda de pesquisa nesse processo. Se realizarmos muitas operações de pesquisa, elas podem estar à frente da fase do limite de pesquisa registrado HexGrid. Isso pode interferir na busca de caminhos da unidade. Para evitar isso, no final do processo de geração do mapa, redefiniremos a fase de pesquisa de todas as células para zero.

  RaiseTerrain(7); for (int i = 0; i < cellCount; i++) { grid.GetCell(i).SearchPhase = 0; } 

Agora RaiseTerraintenho que procurar as células apropriadas e não selecioná-las aleatoriamente. Esse processo é muito semelhante ao método de pesquisa em HexGrid. No entanto, não visitaremos células mais de uma vez, portanto, será suficiente aumentar a fase da borda de pesquisa em 1 em vez de 2. Em seguida, inicializamos a borda com a primeira célula, que é selecionada aleatoriamente. Como sempre, além de definir sua fase de pesquisa, atribuímos sua distância e heurística a zero.

  void RaiseTerrain (int chunkSize) { // for (int i = 0; i < chunkSize; i++) { // GetRandomCell().TerrainTypeIndex = 1; // } searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(); firstCell.SearchPhase = searchFrontierPhase; firstCell.Distance = 0; firstCell.SearchHeuristic = 0; searchFrontier.Enqueue(firstCell); } 

Depois disso, o loop de pesquisa será familiar para nós. Além disso, para continuar a busca até que a borda esteja vazia, precisamos parar quando o fragmento atingir o tamanho desejado, para que o rastreamos. A cada iteração, extrairemos a próxima célula da fila, definiremos o tipo de relevo, aumentaremos o tamanho e desviaremos os vizinhos dessa célula. Todos os vizinhos são simplesmente adicionados à fronteira se ainda não foram adicionados lá. Não precisamos fazer alterações ou comparações. Após a conclusão, você precisa limpar a borda.

  searchFrontier.Enqueue(firstCell); int size = 0; while (size < chunkSize && searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.TerrainTypeIndex = 1; size += 1; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor && neighbor.SearchPhase < searchFrontierPhase) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = 0; neighbor.SearchHeuristic = 0; searchFrontier.Enqueue(neighbor); } } } searchFrontier.Clear(); 


Uma linha de células.

Temos um único gráfico do tamanho certo. Será menor apenas se não houver um número suficiente de células. Devido à maneira como a borda é preenchida, o gráfico sempre consiste em uma linha que segue para noroeste. Ele muda de direção somente quando atinge a borda do mapa.

Nós conectamos células


As áreas terrestres raramente se assemelham a linhas e, se o fazem, nem sempre são orientadas da mesma maneira. Para alterar a forma do site, precisamos alterar as prioridades das células. A primeira célula aleatória pode ser usada como o centro do gráfico. Então a distância para todas as outras células será relativa a este ponto. Portanto, daremos maior prioridade às células mais próximas do centro, para que o site não cresça como uma linha, mas ao redor do centro.

  searchFrontier.Enqueue(firstCell); HexCoordinates center = firstCell.coordinates; int size = 0; while (size < chunkSize && searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.TerrainTypeIndex = 1; size += 1; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor && neighbor.SearchPhase < searchFrontierPhase) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = neighbor.coordinates.DistanceTo(center); neighbor.SearchHeuristic = 0; searchFrontier.Enqueue(neighbor); } } } 


A acumulação de células.

De fato, agora nossas sete células são lindamente agrupadas em uma área hexagonal compacta se a célula central não aparecer na borda do mapa. Vamos tentar agora usar um tamanho de plotagem de 30.

  RaiseTerrain(30); 


Massa de sushi em 30 células.

Novamente, temos a mesma forma, embora não houvesse células suficientes para obter o hexágono certo. Como o raio da plotagem é maior, é mais provável que esteja próximo à borda do mapa, o que forçará a assumir uma forma diferente.

Randomização de sushi


Como não queremos que todas as áreas tenham a mesma aparência, alteraremos ligeiramente as prioridades das células. Cada vez que adicionamos uma célula vizinha à borda, se o próximo número for Random.valuemenor que um determinado valor limite, a heurística dessa célula não será 0, mas 1. Vamos usar o valor 0,5 como limite, ou seja, provavelmente afetará metade das células.

  neighbor.Distance = neighbor.coordinates.DistanceTo(center); neighbor.SearchHeuristic = Random.value < 0.5f ? 1: 0; searchFrontier.Enqueue(neighbor); 


Área distorcida.

Ao aumentar a heurística de pesquisa da célula, a visitamos mais tarde do que o esperado. Ao mesmo tempo, outras células localizadas um passo adiante do centro serão visitadas mais cedo, a menos que também aumentem a heurística. Isso significa que, se aumentarmos a heurística de todas as células em um valor, isso não afetará o mapa. Ou seja, o limite 1 não terá efeito, como o limite 0. E o limite 0,8 será equivalente a 0,2. Ou seja, a probabilidade de 0,5 torna o processo de pesquisa o mais "trêmulo".

A quantidade apropriada de oscilação depende do tipo de terreno desejado, então vamos personalizá-lo. Inclua um campo flutuante genérico jitterProbabilitycom o atributo no geradorRangelimitado no intervalo de 0 a 0,5. Vamos atribuir a ele um valor padrão igual à média desse intervalo, ou seja, 0,25. Isso nos permitirá configurar o gerador na janela do inspetor do Unity.

  [Range(0f, 0.5f)] public float jitterProbability = 0.25f; 


Probabilidade de flutuações.

Você pode personalizá-lo na interface do jogo?
, . UI, . , UI. , . , .

Agora, para tomar uma decisão sobre quando a heurística deve ser igual a 1, usamos probabilidade em vez de um valor constante.

  neighbor.SearchHeuristic = Random.value < jitterProbability ? 1: 0; 

Usamos os valores heurísticos 0 e 1. Embora valores maiores possam ser usados, isso piorará bastante a deformação das seções, provavelmente transformando-as em várias listras.

Levantar um pouco de terra


Não estaremos limitados à geração de um pedaço de terra. Por exemplo, fazemos uma chamada RaiseTerraindentro de um loop para obter cinco seções.

  for (int i = 0; i < 5; i++) { RaiseTerrain(30); } 


Cinco lotes de terra.

Embora agora estamos gerando cinco parcelas de 30 células cada, mas não necessariamente recebemos exatamente 150 células de terra. Como cada site é criado separadamente, eles não se conhecem e, portanto, podem se cruzar. Isso é normal porque pode criar paisagens mais interessantes do que apenas um conjunto de seções isoladas.

Para aumentar a variabilidade da terra, também podemos alterar o tamanho de cada parcela. Adicione dois campos inteiros para controlar os tamanhos mínimo e máximo dos gráficos. Atribua a eles um intervalo suficientemente grande, por exemplo, 20-200. Farei o mínimo padrão igual a 30 e o máximo padrão - 100.

  [Range(20, 200)] public int chunkSizeMin = 30; [Range(20, 200)] public int chunkSizeMax = 100; 


Intervalo de dimensionamento.

Usamos esses campos para determinar aleatoriamente o tamanho da área quando chamada RaiseTerrain.

  RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1)); 


Cinco seções de tamanho aleatório no mapa do meio.

Crie bastante sushi


Embora não possamos controlar particularmente a quantidade de terra gerada. Embora possamos adicionar a opção de configuração para o número de gráficos, os gráficos são de tamanho aleatório e podem se sobrepor leve ou fortemente. Portanto, o número de sites não garante o recebimento no mapa da quantidade necessária de terra. Vamos adicionar uma opção para controlar diretamente a porcentagem de terra expressa como um número inteiro. Como 100% de terra ou água não é muito interessante, limitamos o intervalo de 5 a 95, com um valor de 50 por padrão.

  [Range(5, 95)] public int landPercentage = 50; 


Porcentagem de sushi.

Para garantir a criação da quantidade certa de terra, precisamos apenas continuar a aumentar as áreas do relevo até obtermos uma quantidade suficiente. Para fazer isso, precisamos controlar o processo, o que complicará a geração de terras. Portanto, vamos substituir o ciclo existente de aumentar sites chamando um novo método CreateLand. A primeira coisa que esse método faz é calcular o número de células que devem se tornar terras. Esse valor será nossa soma total de células de sushi.

  public void GenerateMap (int x, int z) { … // for (int i = 0; i < 5; i++) { // RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1)); // } CreateLand(); for (int i = 0; i < cellCount; i++) { grid.GetCell(i).SearchPhase = 0; } } void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); } 

CreateLandcausará RaiseTerrainaté que gastamos toda a quantidade de células. Para não exceder o valor, alteramos RaiseTerrainpara que ele receba o valor como um parâmetro adicional. Depois de terminar o trabalho, ele deve devolver o valor restante.

 // void RaiseTerrain (int chunkSize) { int RaiseTerrain (int chunkSize, int budget) { … return budget; } 

A quantidade deve diminuir cada vez que a célula é removida da borda e convertida em terra. Se depois disso o valor total for gasto, devemos interromper a pesquisa e concluir o site. Além disso, isso deve ser feito apenas quando a célula atual ainda não estiver em terra.

  while (size < chunkSize && searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); if (current.TerrainTypeIndex == 0) { current.TerrainTypeIndex = 1; if (--budget == 0) { break; } } size += 1; … } 

Agora ele CreateLandpode cultivar terras até gastar toda a quantidade de células.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); while (landBudget > 0) { landBudget = RaiseTerrain( Random.Range(chunkSizeMin, chunkSizeMax + 1), landBudget ); } } 


Exatamente metade do mapa se tornou terra.

unitypackage

Leve em consideração a altura


A terra não é apenas uma placa plana, limitada pelo litoral. Ela tem uma altura variável, contendo colinas, montanhas, vales, lagos e assim por diante. Existem grandes diferenças de altura devido à interação de placas tectônicas em movimento lento. Embora não o simulemos, nossas áreas de terra devem, de alguma forma, se assemelhar a essas placas. Os sites não se movem, mas podem se cruzar. E podemos tirar proveito disso.

Empurre a terra


Cada parcela representa uma porção de terra retirada do fundo do oceano. Portanto, vamos aumentar constantemente a altura da célula atual RaiseTerraine ver o que acontece.

  HexCell current = searchFrontier.Dequeue(); current.Elevation += 1; if (current.TerrainTypeIndex == 0) { … } 


Terreno com alturas.

Temos as alturas, mas é difícil de ver. Você pode torná-los mais legíveis se usar seu próprio tipo de terreno para cada nível de altura, como camadas geográficas. Só faremos isso para que as alturas sejam mais visíveis, para que você possa simplesmente usar o nível de altura como um índice de elevação.

O que acontece se a altura exceder o número de tipos de terreno?
. , .

Em vez de atualizar o tipo de terreno da célula com cada alteração de altura, vamos criar um método separado SetTerrainTypepara definir todos os tipos de terreno apenas uma vez.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); cell.TerrainTypeIndex = cell.Elevation; } } 

Vamos chamar esse método depois de criar o sushi.

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

Agora ele RaiseTerrainnão pode lidar com o tipo de alívio e se concentrar nas alturas. Para fazer isso, você precisa alterar sua lógica. Se a nova altura da célula atual for 1, ela ficará mais seca, então a soma das células diminuiu, o que pode levar à conclusão do crescimento do site.

  HexCell current = searchFrontier.Dequeue(); current.Elevation += 1; if (current.Elevation == 1 && --budget == 0) { break; } // if (current.TerrainTypeIndex == 0) { // current.TerrainTypeIndex = 1; // if (--budget == 0) { // break; // } // } 


Estratificação das camadas.

Adicione água


Vamos indicar explicitamente quais células são água ou terra, definindo o nível de água para todas as células como 1. Faça isso GenerateMapantes de criar terra.

  public void GenerateMap (int x, int z) { cellCount = x * z; grid.CreateMap(x, z); if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } for (int i = 0; i < cellCount; i++) { grid.GetCell(i).WaterLevel = 1; } CreateLand(); … } 

Agora, para a designação de camadas de terra, podemos usar todos os tipos de terreno. Todas as células submarinas permanecerão areia, assim como as células terrestres mais baixas. Isso pode ser feito subtraindo o nível da água da altura e usando o valor como um índice do tipo de relevo.

  void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (!cell.IsUnderwater) { cell.TerrainTypeIndex = cell.Elevation - cell.WaterLevel; } } } 


Terra e água.

Aumente o nível da água


Não estamos limitados a um nível de água. Vamos personalizá-lo usando um campo comum com um intervalo de 1 a 5 e um valor padrão de 3. Use esse nível ao inicializar as células.

  [Range(1, 5)] public int waterLevel = 3; … public void GenerateMap (int x, int z) { … for (int i = 0; i < cellCount; i++) { grid.GetCell(i).WaterLevel = waterLevel; } … } 



Nível de água 3.

Quando o nível da água é 3, obtemos menos terra do que esperávamos. Isso ocorre porque RaiseTerrainainda acredita que o nível da água é 1. Vamos corrigi-lo.

  HexCell current = searchFrontier.Dequeue(); current.Elevation += 1; if (current.Elevation == waterLevel && --budget == 0) { break; } 

Usar níveis mais altos de água leva a isso. que as células não se tornam terra imediatamente. Quando o nível da água é 2, a primeira seção ainda permanece debaixo d'água. O fundo do oceano subiu, mas ainda permanece debaixo d'água. Um terreno é formado apenas na interseção de pelo menos duas seções. Quanto maior o nível da água, mais locais devem atravessar para criar terra. Portanto, com o aumento dos níveis da água, a terra se torna mais caótica. Além disso, quando mais parcelas são necessárias, é mais provável que elas se cruzem em terras já existentes, devido às quais montanhas serão mais comuns e terras planas menos prováveis, como no caso de parcelas menores.





Os níveis de água são 2–5, o sushi é sempre 50%.

unitypackage

Movimento vertical


Até agora, aumentamos os gráficos um nível por vez, mas não precisamos nos limitar a isso.

Sites altos


Embora cada seção aumente a altura de suas células em um nível, podem ocorrer recortes. Isso acontece quando as bordas de duas seções se tocam. Isso pode criar falésias isoladas, mas longas linhas de falésia serão raras. Podemos aumentar a frequência de sua aparência aumentando a altura da plotagem em mais de um passo. Mas isso precisa ser feito apenas para uma certa proporção de sites. Se todas as áreas se elevarem, será muito difícil se deslocar ao longo do terreno. Então, vamos tornar esse parâmetro personalizável usando um campo de probabilidade com um valor padrão de 0,25.

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


A probabilidade de um forte aumento nas células.

Embora possamos usar qualquer aumento de altura em áreas altas, isso rapidamente fica fora de controle. A diferença de altura 2 já cria falésias, então isso é suficiente. Como você pode pular uma altura igual ao nível da água, precisamos mudar a maneira como determinamos se uma célula se tornou terra. Se estava abaixo do nível da água e agora está no mesmo nível ou acima, criamos uma nova célula terrestre.

  int rise = Random.value < highRiseProbability ? 2 : 1; int size = 0; while (size < chunkSize && searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); int originalElevation = current.Elevation; current.Elevation = originalElevation + rise; if ( originalElevation < waterLevel && current.Elevation >= waterLevel && --budget == 0 ) { break; } size += 1; … } 





As probabilidades de um forte aumento na altura são 0,25, 0,50, 0,75 e 1.

Abaixe a terra


A terra nem sempre sobe, às vezes cai. Quando a terra cai suficientemente baixo, a água a enche e se perde. Até agora não estamos fazendo isso. Como apenas aumentamos as áreas, a terra geralmente se parece com um conjunto de áreas bastante redondas misturadas. Se às vezes abaixarmos a área, obteremos mais formas variadas.


Grande mapa sem sushi afundado.

Podemos controlar a frequência de subsidência de terras usando outro campo de probabilidade. Como o abaixamento pode destruir a terra, a probabilidade de abaixar deve sempre ser menor do que a probabilidade de elevar. Caso contrário, pode levar muito tempo para obter a porcentagem correta de terra. Portanto, vamos usar uma probabilidade de redução máxima de 0,4 com um valor padrão de 0,2.

  [Range(0f, 0.4f)] public float sinkProbability = 0.2f; 


Probabilidade de baixar.

Baixar o site é semelhante ao aumento, com algumas diferenças. Portanto, duplicamos o método RaiseTerraine alteramos seu nome para SinkTerrain. Em vez de determinar a magnitude do aumento, precisamos de um valor menor que possa usar a mesma lógica. Ao mesmo tempo, é necessário fazer comparações para verificar se passamos pela superfície da água. Além disso, ao diminuir o relevo, não estamos limitados à soma das células. Em vez disso, cada célula de sushi perdida retorna a quantia gasta nela, então aumentamos e continuamos a trabalhar.

  int SinkTerrain (int chunkSize, int budget) { … int sink = Random.value < highRiseProbability ? 2 : 1; int size = 0; while (size < chunkSize && searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); int originalElevation = current.Elevation; current.Elevation = originalElevation - sink; if ( originalElevation >= waterLevel && current.Elevation < waterLevel // && --budget == 0 ) { // break; budget += 1; } size += 1; … } searchFrontier.Clear(); return budget; } 

Agora, a cada iteração interna, CreateLanddevemos diminuir ou elevar a terra, dependendo da probabilidade de queda.

  void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); while (landBudget > 0) { int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (Random.value < sinkProbability) { landBudget = SinkTerrain(chunkSize, landBudget); } else { landBudget = RaiseTerrain(chunkSize, landBudget); } } } 





A probabilidade de queda é de 0,1, 0,2, 0,3 e 0,4.

Altura limite


No estágio atual, podemos potencialmente sobrepor muitas seções, às vezes com vários aumentos de altura, alguns dos quais podem descer e subir novamente. Ao mesmo tempo, podemos criar alturas muito altas e, às vezes, muito baixas, especialmente quando é necessária uma alta porcentagem de terra.


Alturas enormes em 90% da terra.

Para limitar a altura, vamos adicionar um mínimo e um máximo personalizados. Um mínimo razoável estará entre -4 e 0 e um máximo aceitável pode estar no intervalo de 6 a 10. Deixe os valores padrão serem -2 e 8. Ao editar manualmente o mapa, eles estarão fora do limite aceitável, para que você possa alterar o controle deslizante da interface do usuário do editor ou deixá-lo como está.

  [Range(-4, 0)] public int elevationMinimum = -2; [Range(6, 10)] public int elevationMaximum = 8; 


Alturas mínima e máxima.

Agora RaiseTerraindevemos garantir que a altura não exceda o máximo permitido. Isso pode ser feito verificando se as células atuais estão muito altas. Nesse caso, ignoramos sem alterar a altura e adicionar seus vizinhos. Isso levará ao fato de que as áreas terrestres evitarão áreas que atingiram uma altura máxima e crescerão ao redor delas.

  HexCell current = searchFrontier.Dequeue(); int originalElevation = current.Elevation; int newElevation = originalElevation + rise; if (newElevation > elevationMaximum) { continue; } current.Elevation = newElevation; if ( originalElevation < waterLevel && newElevation >= waterLevel && --budget == 0 ) { break; } size += 1; 

Faça o mesmo SinkTerrain, mas para uma altura mínima.

  HexCell current = searchFrontier.Dequeue(); int originalElevation = current.Elevation; int newElevation = current.Elevation - sink; if (newElevation < elevationMinimum) { continue; } current.Elevation = newElevation; if ( originalElevation >= waterLevel && newElevation < waterLevel ) { budget += 1; } size += 1; 


Altura limitada com 90% de terreno.

Preservação de altitude negativa


Nesse momento, o código de salvamento e carregamento não pode lidar com alturas negativas porque armazenamos a altura como byte. Um número negativo é convertido quando salvo em um positivo grande. Portanto, ao salvar e carregar o mapa gerado, podem aparecer mapas muito altos no lugar das células subaquáticas originais.

Podemos adicionar suporte para alturas negativas armazenando-o como um número inteiro, não como um byte. No entanto, ainda não precisamos oferecer suporte a vários níveis de altura. Além disso, podemos compensar o valor armazenado adicionando 127. Isso nos permitirá armazenar corretamente alturas no intervalo −127-128 em um byte. Mude de HexCell.Saveacordo.

  public void Save (BinaryWriter writer) { writer.Write((byte)terrainTypeIndex); writer.Write((byte)(elevation + 127)); … } 

Como alteramos a maneira como salvamos os dados do mapa, aumentamos SaveLoadMenu.mapFileVersionpara 4.

  const int mapFileVersion = 4; 

E, finalmente, altere-o HexCell.Loadpara que subtraia 127 das alturas carregadas dos arquivos da versão 4.

  public void Load (BinaryReader reader, int header) { terrainTypeIndex = reader.ReadByte(); ShaderData.RefreshTerrain(this); elevation = reader.ReadByte(); if (header >= 4) { elevation -= 127; } … } 

unitypackage

Recriando o mesmo mapa


Agora podemos criar uma grande variedade de mapas. Ao gerar cada novo resultado, será aleatório. Podemos controlar usando as opções de configuração apenas as características do cartão, mas não a forma mais precisa. Mas, às vezes, precisamos recriar exatamente o mesmo mapa novamente. Por exemplo, para compartilhar um mapa bonito com um amigo ou começar novamente depois de editá-lo manualmente. Também é útil no processo de desenvolvimento de jogos, então vamos adicionar esse recurso.

Usando sementes


Para tornar o processo de geração de mapas imprevisível, usamos Random.Rangee Random.value. Para obter a mesma sequência pseudo-aleatória de números novamente, você precisa usar o mesmo valor inicial. Já adotamos uma abordagem semelhante antes, em HexMetrics.InitializeHashGrid. Primeiro, ele salva o estado atual do gerador de números inicializado com um valor de semente específico e depois restaura seu estado original. Podemos usar a mesma abordagem para HexMapGenerator.GenerateMap. Podemos lembrar novamente o estado antigo e restaurá-lo após a conclusão, para não interferir com qualquer outra coisa que use Random.

  public void GenerateMap (int x, int z) { Random.State originalRandomState = Random.state; … Random.state = originalRandomState; } 

Em seguida, precisamos disponibilizar a semente usada para gerar o último cartão. Isso é feito usando um campo inteiro comum.

  public int seed; 


Exibir semente.

Agora precisamos do valor inicial para inicializar Random. Para criar cartões aleatórios, você precisa usar uma semente aleatória. A abordagem mais simples é usar um valor de semente arbitrário para gerar Random.Range. Para que ele não afete o estado aleatório inicial, precisamos fazer isso depois de salvá-lo.

  public void GenerateMap (int x, int z) { Random.State originalRandomState = Random.state; seed = Random.Range(0, int.MaxValue); Random.InitState(seed); … } 

Como após a conclusão, restauramos um estado aleatório e, se gerarmos outra carta imediatamente, obtemos o mesmo valor inicial. Além disso, não sabemos como o estado aleatório inicial foi inicializado. Portanto, embora possa servir como um ponto de partida arbitrário, precisamos de algo mais para randomizá-lo a cada chamada.

Existem várias maneiras de inicializar geradores de números aleatórios. Nesse caso, você pode simplesmente combinar vários valores arbitrários que variam em uma ampla faixa, ou seja, a probabilidade de gerar novamente a mesma placa será baixa. Por exemplo, usamos os 32 bits mais baixos do tempo do sistema, expressos em ciclos, mais o tempo de execução atual do aplicativo. Combine esses valores usando a operação OR exclusiva bit a bit para que o resultado não seja muito grande.

  seed = Random.Range(0, int.MaxValue); seed ^= (int)System.DateTime.Now.Ticks; seed ^= (int)Time.unscaledTime; Random.InitState(seed); 

O número resultante pode ser negativo, o que para uma semente de valor público não parece muito agradável. Podemos torná-lo estritamente positivo usando mascaramento bit a bit com um valor inteiro máximo que redefinirá o bit de sinal.

  seed ^= (int)Time.unscaledTime; seed &= int.MaxValue; Random.InitState(seed); 

Semente reutilizável


Ainda geramos cartões aleatórios, mas agora podemos ver qual valor inicial foi usado para cada um deles. Para recriar o mesmo mapa novamente, precisamos ordenar que o gerador use o mesmo valor inicial novamente, em vez de criar um novo. Faremos isso adicionando uma opção usando um campo booleano.

  public bool useFixedSeed; 


Opção para usar uma semente constante.

Se uma semente constante for selecionada, simplesmente pularemos a geração da nova semente GenerateMap. Se não alterarmos manualmente o campo de propagação, o resultado será o mesmo mapa novamente.

  Random.State originalRandomState = Random.state; if (!useFixedSeed) { seed = Random.Range(0, int.MaxValue); seed ^= (int)System.DateTime.Now.Ticks; seed ^= (int)Time.time; seed &= int.MaxValue; } Random.InitState(seed); 

seed - , . , , , , . . seed .



seed 0 929396788, .

unitypackage

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


All Articles