Partes 1-3: malha, cores e altura das célulasPartes 4-7: solavancos, rios e estradasPeças 8-11: água, formas terrestres e muralhasPeças 12-15: salvar e carregar, texturas, distânciasPartes 16-19: encontrando o caminho, esquadrões de jogadores, animaçõesPartes 20-23: Nevoeiro da Guerra, Pesquisa de Mapas, Geração de ProcedimentosPartes 24-27: ciclo da água, erosão, biomas, mapa cilíndricoParte 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;
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
.
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.
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(); }
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); }
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);
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);
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);
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);
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 );
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 );
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);
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);
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 GetCellData
trabalho 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 tex2Dlod
que 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.terrain
seus í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.unitypackageVisibilidade
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 GetTerrainColor
pelo 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 lerp
no 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 HexCell
dois 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 HexCellShaderData
mé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 IncreaseVisibility
para 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 HexGrid
mé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 propriedadesPathFrom
eles 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);
Não podemos usar um algoritmo mais simples para encontrar todas as células dentro do alcance?, , .
Adicione também HexGrid
métodos IncreaseVisibility
e 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 HexUnit
requer 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 à HexUnit
constante, 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 IncreaseVisibility
e 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) {
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 Location
c 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) {
Dentro das corotinas, TravelPath
reduziremos 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;
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, TravelPath
podemos 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; } } }
unitypackageVisibilidade 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.TriangulateRoadEdge
usado 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
, TriangulateRoadEdge
e TriangulateRoadSegment
para 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;
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 TriangulateEstuary
e 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.TriangulateRiverQuad
vetor 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); }
TriangulateWithRiverBeginOrEnd
cria 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 TriangulateConnection
para 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.unitypackageObjetos 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á- GetCellData
lo 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 tex2Dlod
um 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.unitypackageParte 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.EnableKeyWord
e Shader.DisableKeyword
. Iremos chamar o método apropriado HexGameUI.SetEditMode
ao 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 GetCellData
antes 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á.unitypackagePesquisa 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 à HexCell
propriedade 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, IsExplored
um 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.RefreshVisibility
armazena 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 FilterCellData
para 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 visibility
quarto 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 SaveLoadMenu
constante para isso . const int mapFileVersion = 3;
Usaremos essa constante ao gravar a versão do arquivo Save
e 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.Save
registramos o status do estudo. public void Save (BinaryWriter writer) { … writer.Write(IsExplored); }
E vamos ler no final Load
. Depois disso, ligaremos RefreshVisibility
caso 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 Load
dados do cabeçalho como parâmetro . public void Load (BinaryReader reader, int header) { … IsExplored = header >= 3 ? reader.ReadBoolean() : false; ShaderData.RefreshVisibility(this); }
Agora HexGrid.Load
terá que passar os HexCell.Load
dados 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.unitypackageOcultar 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;
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 surf
precisa 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 explored
cor 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? Á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 explored
parâ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.Emission
feito 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;
visibility
també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 vert
para que ele use explicitamente os dados de visibilidade data.visibility.x
e atribua o data.visibility.y
valor 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 surf
para 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 visibility
mais 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 surf
operações diferentes, mas definem suas propriedades de superfície da mesma maneira. Como são transparentes, levaremos em consideração explore
o 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 visibility
dados 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 surf
sombreadores 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.unitypackageEvitando 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 HexGrid
para HexUnit
. Isso simplificará o suporte para unidades com diferentes regras de movimento.Adicione ao HexUnit
método geral GetMoveCost
para 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.Search
para 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 à HexUnit
propriedade Speed
. Enquanto isso retornará um valor constante de 24. public int Speed { get { return 24; } }
Em HexGrid
mudança, FindPath
e Search
para 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 Search
có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.IsValidDestination
e 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; }
Ignorar áreas inexploradas
Para evitar células inexploradas, basta garantir que HexUnit.IsValidDestination
verifiquemos 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?. , . .
, , . , .
unitypackageParte 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 à HexCellShaderData
propriedade 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 RefreshVisibility
diretamente. 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 RemoveAt
todas 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á UpdateCellData
pronto 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 chamadasRefreshVisibility
caro, 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.Load
para 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; }
unitypackageEscopo 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 à HexCell
propriedade 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ê HexCellShaderData
tenha 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ê HexCellShaderData
não possui. Então, vamos delegar essa responsabilidade HexGrid
. Para fazer isso, você precisa adicionar à HexCellShaderData
propriedade, o que permite que você consulte a grade. Em seguida, podemos usá-lo LateUpdate
para 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.Awake
depois 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); }
HexGrid
também deve obter um método ResetVisibility
para 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 HexCell
um 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.ResetVisibility
ele 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.visionRange
em HexUnit.VisionRange
e 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.OnEnable
quando 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 fromCell
redefinindo 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?, , . , , . .
unitypackageCé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 HexCell
propriedade 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.GetVisibleCells
para levar isso em consideração. if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase || !neighbor.Explorable ) { continue; }
unitypackageParte 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.Awake
definindo 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 HexMapGenerator
e HexGrid
nã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 GenerateMap
que 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 HexMapGenerator
e conecte-o à grade.Objeto gerador de mapa.Alterar o menu de um novo mapa
Vamos alterá-lo NewMapMenu
para 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 GenerateMap
gerador , se necessário , e não apenas executaremos a CreateMap
grade. 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 HexGrid
já temos métodos comuns GetCell
que 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.GetCell
que 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 HexMapGenerator
pode 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.unitypackageFazendo 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 RaiseTerrain
com 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);
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 RaiseTerrain
pegar 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 GetRandomCell
que 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 HexMapGenerator
nossa 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 RaiseTerrain
tenho 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) {
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.value
menor 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 jitterProbability
com o atributo no geradorRange
limitado 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 RaiseTerrain
dentro 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) { …
CreateLand
causará RaiseTerrain
até que gastamos toda a quantidade de células. Para não exceder o valor, alteramos RaiseTerrain
para que ele receba o valor como um parâmetro adicional. Depois de terminar o trabalho, ele deve devolver o valor restante.
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 CreateLand
pode 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.unitypackageLeve 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 RaiseTerrain
e 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 SetTerrainType
para 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 RaiseTerrain
nã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; }
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 GenerateMap
antes 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 RaiseTerrain
ainda 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%.unitypackageMovimento 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 RaiseTerrain
e 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
Agora, a cada iteração interna, CreateLand
devemos 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 RaiseTerrain
devemos 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.Save
acordo. 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.mapFileVersion
para 4. const int mapFileVersion = 4;
E, finalmente, altere-o HexCell.Load
para 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; } … }
unitypackageRecriando 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.Range
e 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