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 12: salvar e carregar
- Acompanhe o tipo de terreno em vez de cor.
- Crie um arquivo
- Nós escrevemos os dados em um arquivo e depois lemos.
- Serializamos os dados da célula.
- Reduza o tamanho do arquivo.
Já sabemos como criar mapas bastante interessantes. Agora você precisa aprender como salvá-los.
Carregado do arquivo test.map .Tipo de terreno
Ao salvar um mapa, não precisamos armazenar todos os dados que rastreamos durante a execução do aplicativo. Por exemplo, precisamos apenas lembrar o nível de altura da célula. Sua própria posição vertical é retirada desses dados, para que você não precise armazená-los. Na verdade, é melhor não armazenar essas métricas calculadas. Assim, os dados do mapa permanecerão corretos, mesmo que mais tarde decidamos alterar o deslocamento da altura. Os dados são separados da sua apresentação.
Da mesma forma, não precisamos armazenar a cor exata da célula. Você pode escrever que a célula é verde. Mas o tom exato de verde pode mudar com uma mudança no estilo visual. Para fazer isso, podemos salvar o índice de cores, não as próprias cores. De fato, pode ser suficiente armazenarmos esse índice em vez de cores reais nas células em tempo de execução. Isso permitirá, posteriormente, a visualização mais complexa do relevo.
Movendo uma matriz de cores
Se as células não tiverem mais dados de cores, eles deverão ser armazenados em outro lugar. É mais conveniente armazená-lo no
HexMetrics
. Então, vamos adicionar uma variedade de cores a ele.
public static Color[] colors;
Como todos os outros dados globais, como ruído, podemos inicializar essas cores com o
HexGrid
.
public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; … } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; } }
E como agora não atribuímos cores diretamente às células, nos livraremos da cor padrão.
Defina as novas cores para corresponder à matriz geral do editor de mapas hexagonais.
Cores adicionadas à grade.Refatoração de Células
Remova o campo de cores do
HexCell
. Em vez disso, armazenaremos o índice. Em vez de um índice de cores, usamos um índice mais geral do tipo de relevo.
A propriedade color pode usar esse índice apenas para obter a cor correspondente. Agora, como não está definido diretamente, exclua esta parte. Nesse caso, obtemos um erro de compilação, que corrigiremos em breve.
public Color Color { get { return HexMetrics.colors[terrainTypeIndex]; }
Adicione uma nova propriedade para obter e definir um novo índice de tipo de elevação.
public int TerrainTypeIndex { get { return terrainTypeIndex; } set { if (terrainTypeIndex != value) { terrainTypeIndex = value; Refresh(); } } }
Refatoração do editor
Dentro do
HexMapEditor
todo o código referente às cores. Isso corrigirá o erro de compilação.
Agora adicione um campo e método para controlar o índice do tipo de elevação ativo.
int activeTerrainTypeIndex; … public void SetTerrainTypeIndex (int index) { activeTerrainTypeIndex = index; }
Usamos esse método como um substituto para o método
SelectColor
agora ausente. Conecte os widgets de cores na interface do usuário ao
SetTerrainTypeIndex
, deixando o restante inalterado. Isso significa que um índice negativo ainda está em uso e significa que a cor não deve mudar.
Altere
EditCell
para que o índice do tipo de elevação seja atribuído à célula que está sendo editada.
void EditCell (HexCell cell) { if (cell) { if (activeTerrainTypeIndex >= 0) { cell.TerrainTypeIndex = activeTerrainTypeIndex; } … } }
Embora tenhamos removido os dados de cores das células, o mapa deve funcionar da mesma maneira que antes. A única diferença é que a cor padrão agora é a primeira da matriz. No meu caso, é amarelo.
Amarelo é a nova cor padrão.unitypackageSalvando dados em um arquivo
Para controlar o salvamento e o carregamento do mapa, usamos o
HexMapEditor
. Vamos criar dois métodos que farão isso e, por enquanto, deixá-los vazios.
public void Save () { } public void Load () { }
Adicione dois botões à interface do usuário (
GameObject / UI / Button ). Conecte-os aos botões e dê as etiquetas apropriadas. Coloquei-os na parte inferior do painel direito.
Botões Salvar e Carregar.Local do arquivo
Para armazenar um cartão, você precisa salvá-lo em algum lugar. Como é feito na maioria dos jogos, armazenaremos dados em um arquivo. Mas onde colocar esse arquivo no sistema de arquivos? A resposta depende do sistema operacional em que o jogo está sendo executado. Cada sistema operacional possui seus próprios padrões para armazenar arquivos relacionados a aplicativos.
Não precisamos conhecer esses padrões. O Unity sabe o caminho certo que podemos obter com
Application.persistentDataPath
. Você pode verificar como será com você, no método
Save
, exibindo-o no console e pressionando o botão no modo Reproduzir.
public void Save () { Debug.Log(Application.persistentDataPath); }
Nos sistemas de desktop, o caminho conterá o nome da empresa e do produto. Esse caminho é usado pelo editor e pela montagem. Os nomes podem ser configurados em
Editar / Configurações do projeto / Player .
Nome da empresa e produto.Por que não consigo encontrar a pasta Biblioteca no Mac?A pasta Biblioteca geralmente está oculta. A maneira como ele pode ser exibido depende da versão do OS X. Se você não possui uma versão anterior, selecione a pasta pessoal no Finder e vá para Mostrar opções de exibição . Há uma caixa de seleção para a pasta Biblioteca .
E o WebGL?Os jogos WebGL não podem acessar o sistema de arquivos do usuário. Em vez disso, todas as operações de arquivo são redirecionadas para um sistema de arquivos localizado na memória. Ela é transparente para nós. No entanto, para salvar dados, você precisará solicitar manualmente a página da Web para despejar dados no armazenamento do navegador.
Criação de arquivo
Para criar um arquivo, precisamos usar classes do espaço para nome
System.IO
. Portanto, adicionamos uma instrução
using
para ela na classe
HexMapEditor
.
using UnityEngine; using UnityEngine.EventSystems; using System.IO; public class HexMapEditor : MonoBehaviour { … }
Primeiro, precisamos criar o caminho completo para o arquivo. Usamos
test.map como o
nome do arquivo. Ele deve ser adicionado ao caminho dos dados armazenados. A necessidade de inserir uma barra invertida ou dianteira (barra invertida) depende da plataforma. O método
Path.Combine
fará
Path.Combine
.
public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); }
Em seguida, precisamos acessar o arquivo neste local. Fazemos isso usando o método
File.Open
. Como queremos gravar dados nesse arquivo, precisamos usar o modo de criação. Nesse caso, um novo arquivo será criado no caminho especificado ou um arquivo existente será substituído.
string path = Path.Combine(Application.persistentDataPath, "test.map"); File.Open(path, FileMode.Create);
O resultado da chamada desse método será um fluxo de dados aberto associado a esse arquivo. Podemos usá-lo para gravar dados em um arquivo. E não devemos esquecer de fechar o fluxo quando não precisarmos mais dele.
string path = Path.Combine(Application.persistentDataPath, "test.map"); Stream fileStream = File.Open(path, FileMode.Create); fileStream.Close();
Nesse estágio, quando você clica no botão
Salvar , o arquivo
test.map será criado na pasta especificada como o caminho para os dados armazenados. Se você estudar esse arquivo, ele estará vazio e terá um tamanho de 0 bytes, porque até agora não escrevemos nada nele.
Gravar no arquivo
Para gravar dados em um arquivo, precisamos de uma maneira de transmitir dados para ele. A maneira mais fácil de fazer isso é com o
BinaryWriter
. Esses objetos permitem gravar dados primitivos em qualquer fluxo.
Crie um novo objeto
BinaryWriter
e nosso fluxo de arquivos será seu argumento. O escritor de fechamento fecha o fluxo usado. Portanto, não precisamos mais armazenar um link direto para o fluxo.
string path = Path.Combine(Application.persistentDataPath, "test.map"); BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Close();
Para transferir dados para um fluxo, podemos usar o método
BinaryWriter.Write
. Existe uma variante do método
Write
para todos os tipos primitivos, como inteiro e float. Também pode gravar linhas. Vamos tentar escrever o número inteiro 123.
BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Write(123); writer.Close();
Clique no botão
Salvar e examine
test.map novamente. Agora, seu tamanho é de 4 bytes, porque o tamanho inteiro é de 4 bytes.
Por que meu gerenciador de arquivos mostra que o arquivo ocupa mais espaço?Porque os sistemas de arquivos dividem o espaço em blocos de bytes. Eles não controlam bytes individuais. Como test.map leva apenas quatro bytes até agora, requer um bloco de espaço de armazenamento.
Observe que armazenamos dados binários, não textos legíveis por humanos. Portanto, se abrirmos o arquivo em um editor de texto, veremos um conjunto de caracteres indistintos. Você provavelmente verá o símbolo
{ seguido por nada ou alguns espaços reservados.
Você pode abrir o arquivo em um editor hexadecimal. Nesse caso, veremos
7b 00 00 00 . Estes são quatro bytes do nosso número inteiro, mapeados em notação hexadecimal. Em números decimais comuns, isso é
123 0 0 0 . Em binário, o primeiro byte se parece com
01111011 .
O código ASCII para
{ é 123, portanto, esse caractere pode ser exibido em um editor de texto. ASCII 0 é um caractere nulo que não corresponde a nenhum caractere visível.
Os três bytes restantes são iguais a zero, porque escrevemos um número menor que 256. Se escrevêssemos 256, veríamos
00 01 00 00 no editor hexadecimal.
123 não deve ser armazenado como 00 00 00 7b?BinaryWriter
usa o formato little-endian para salvar números. Isso significa que os bytes menos significativos são gravados primeiro. Este formato foi usado pela Microsoft no desenvolvimento da estrutura .Net. Provavelmente foi escolhido porque o CPU Intel usa o formato little-endian.
Uma alternativa é o big-endian, no qual os bytes mais significativos são armazenados primeiro. Isso corresponde à ordem usual de números em números. 123 é cento e vinte e três porque queremos dizer o recorde de big endian. Se fosse um pequeno endian, 123 significaria trezentos e vinte e um.
Tornamos os recursos livres
É importante que fechemos escritor. Enquanto está aberto, o sistema de arquivos bloqueia o arquivo, impedindo que outros processos sejam gravados nele. Se esquecermos de fechá-lo, também nos bloquearemos. Se pressionarmos o botão Salvar duas vezes, na segunda vez não poderemos abrir o fluxo.
Em vez de fechar o gravador manualmente, podemos criar um bloco
using
para isso. Ele define o escopo dentro do qual o escritor é válido. Quando o código executável ultrapassa esse escopo, o gravador é excluído e o thread é fechado.
using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(123); }
Isso funcionará porque as classes de gravador e de fluxo de arquivos implementam a interface
IDisposable
. Esses objetos têm um método
Dispose
, que é chamado indiretamente quando eles vão além do escopo de
using
.
A grande vantagem do
using
é que ele funciona, não importa como o programa fique fora do escopo. Retornos antecipados, exceções e erros não o incomodam. Além disso, ele é muito conciso.
Recuperação de dados
Para ler dados escritos anteriormente, precisamos inserir o código no método
Load
. Como no caso de salvar, precisamos criar um caminho e abrir o fluxo de arquivos. A diferença é que agora abrimos o arquivo para leitura, não para gravação. E, em vez de escritor, precisamos do
BinaryReader
.
public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryReader reader = new BinaryReader(File.Open(path, FileMode.Open)) ) { } }
Nesse caso, podemos usar o método
File.OpenRead
para abrir o arquivo para leitura.
using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { }
Por que não podemos usar o File.OpenWrite ao escrever?Esse método cria um fluxo que adiciona dados aos arquivos existentes, em vez de substituí-los.
Ao ler, precisamos indicar explicitamente o tipo de dados recebidos. Para ler números inteiros de um fluxo, precisamos usar
BinaryReader.ReadInt32
. Este método lê um número inteiro de 32 bits, ou seja, quatro bytes.
using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { Debug.Log(reader.ReadInt32()); }
Note-se que, ao receber
123, será suficiente lermos um byte. Mas, ao mesmo tempo, três bytes pertencentes a esse número inteiro permanecerão no fluxo. Além disso, isso não funcionará para números fora do intervalo de 0 a 255. Portanto, não faça isso.
unitypackageEscrevendo e lendo dados do mapa
Ao salvar dados, uma pergunta importante é se deve ser usado um formato legível por humanos. Normalmente, os formatos legíveis por humanos são JSON, XML e ASCII simples com algum tipo de estrutura. Esses arquivos podem ser abertos, interpretados e editados em editores de texto. Além disso, eles simplificam a troca de dados entre diferentes aplicativos.
No entanto, esses formatos têm seus próprios requisitos. Os arquivos ocupam mais espaço (às vezes muito mais) do que usando dados binários. Eles também podem aumentar bastante o custo de codificação e decodificação de dados, em termos de tempo de execução e de espaço na memória.
Por outro lado, os dados binários são compactos e rápidos. Isso é importante ao gravar grandes quantidades de dados. Por exemplo, ao salvar automaticamente um mapa grande em cada turno do jogo. Portanto,
vamos usar o formato binário. Se você conseguir lidar com isso, poderá trabalhar com formatos mais detalhados.
E a serialização automática?Imediatamente durante o processo de serialização dos dados do Unity, podemos escrever diretamente classes serializadas no fluxo. Detalhes da gravação de campos individuais serão escondidos de nós. No entanto, não podemos serializar diretamente as células. São classes MonoBehaviour
que contêm dados que não precisamos salvar. Portanto, precisamos usar uma hierarquia separada de objetos, que destrua a simplicidade da serialização automática. Além disso, será mais difícil oferecer suporte a futuras alterações de código. Portanto, manteremos controle total com serialização manual. Além disso, nos fará realmente entender o que está acontecendo.
Para serializar o mapa, precisamos armazenar os dados de cada célula. Para salvar e carregar uma única célula, adicione os métodos
Save
and
Load
ao
HexCell
. Como eles precisam de um escritor ou leitor para trabalhar, nós os adicionaremos como parâmetros.
using UnityEngine; using System.IO; public class HexCell : MonoBehaviour { … public void Save (BinaryWriter writer) { } public void Load (BinaryReader reader) { } }
Adicione métodos
Save
e
Load
ao
HexGrid
. Esses métodos simplesmente ignoram todas as células chamando seus métodos
Load
e
Save
.
using UnityEngine; using UnityEngine.UI; using System.IO; public class HexGrid : MonoBehaviour { … public void Save (BinaryWriter writer) { for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } } }
Se fizermos o download de um mapa, ele precisará ser atualizado após a alteração dos dados da célula. Para fazer isso, basta atualizar todos os fragmentos.
public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } }
Finalmente, substituímos nosso código de teste no
HexMapEditor
pelas chamadas para os métodos
Save
and
Load
da grade, passando o escritor ou o leitor com eles.
public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { hexGrid.Save(writer); } } public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { hexGrid.Load(reader); } }
Salvando um tipo de relevo
No estágio atual, salvar novamente cria um arquivo vazio e o download não faz nada. Vamos começar gradualmente gravando e carregando apenas o índice do tipo de elevação
HexCell
.
Atribua o valor diretamente ao campo terrainTypeIndex. Não usaremos propriedades. Como atualizamos explicitamente todos os fragmentos, as chamadas para as propriedades de
Refresh
não são necessárias. Além disso, como salvamos apenas os mapas corretos, assumiremos que todos os mapas baixados também estão corretos. Portanto, por exemplo, não verificaremos se o rio ou estrada é permitido.
public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); }
Ao salvar neste arquivo, um após o outro, o índice do tipo de alívio de todas as células será gravado. Como o índice é um número inteiro, seu tamanho é de quatro bytes. Meu cartão contém 300 células, ou seja, o tamanho do arquivo será de 1200 bytes.
A carga lê os índices na mesma ordem em que são gravados. Se você alterou as cores das células após salvar, o carregamento do mapa retornará as cores ao estado ao salvar. Como não salvamos mais nada, o restante dos dados da célula permanecerá o mesmo. Ou seja, o carregamento alterará o tipo de terreno, mas não a sua altura, nível da água, características do terreno, etc.
Salvando todo o número inteiro
Salvar um índice de tipo de alívio não é suficiente para nós. Você precisa salvar todos os outros dados. Vamos começar com todos os campos inteiros. Este é um índice do tipo de relevo, altura da célula, nível da água, nível da cidade, nível da fazenda, nível da vegetação e índice de objetos especiais. Eles precisarão ser lidos na mesma ordem em que foram gravados.
public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); }
Tente agora salvar e carregar o mapa, fazendo alterações entre essas operações. Tudo o que incluímos nos dados armazenados foi restaurado da melhor forma possível, exceto a altura da célula. Isso aconteceu porque quando você altera o nível de altura, é necessário atualizar a posição vertical da célula. Isso pode ser feito atribuindo-o à propriedade, e não ao campo, o valor da altura carregada. Mas essa propriedade faz um trabalho adicional que não precisamos. Portanto, vamos extrair o código que atualiza a posição da célula do levantador de
Elevation
e inseri-lo em um método
RefreshPosition
separado. A única alteração que você precisa fazer aqui é substituir o
value
referência ao campo de
elevation
.
void RefreshPosition () { Vector3 position = transform.localPosition; position.y = elevation * HexMetrics.elevationStep; position.y += (HexMetrics.SampleNoise(position).y * 2f - 1f) * HexMetrics.elevationPerturbStrength; transform.localPosition = position; Vector3 uiPosition = uiRect.localPosition; uiPosition.z = -position.y; uiRect.localPosition = uiPosition; }
Agora podemos chamar o método ao definir a propriedade, bem como depois de carregar os dados da altura.
public int Elevation { … set { if (elevation == value) { return; } elevation = value; RefreshPosition(); ValidateRivers(); … } } … public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); … }
Após essa alteração, as células mudarão corretamente sua altura aparente ao carregar.
Salvando todos os dados
A presença de paredes e rios de entrada / saída na célula é armazenada em campos booleanos. Podemos escrevê-los simplesmente como um número inteiro. Além disso, os dados da estrada são uma matriz de seis valores booleanos que podemos escrever com um loop.
public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write(hasOutgoingRiver); for (int i = 0; i < roads.Length; i++) { writer.Write(roads[i]); } }
As direções dos rios de entrada e saída são armazenadas nos campos
HexDirection
. O tipo
HexDirection
é uma enumeração armazenada internamente como vários valores inteiros. Portanto, também podemos serializá-los como um número inteiro usando uma conversão explícita.
writer.Write(hasIncomingRiver); writer.Write((int)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((int)outgoingRiver);
Os valores booleanos são lidos usando o método
BinaryReader.ReadBoolean
. As direções dos rios são inteiras, que devemos converter novamente em
HexDirection
.
public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadInt32(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadInt32(); for (int i = 0; i < roads.Length; i++) { roads[i] = reader.ReadBoolean(); } }
Agora, salvamos todos os dados da célula necessários para o salvamento e a restauração completos do mapa. Isso requer nove números inteiros e nove valores booleanos por célula.
Cada valor booleano ocupa um byte, portanto, usamos um total de 45 bytes por célula. Ou seja, um cartão com 300 células requer um total de 13.500 bytes.unitypackageReduzir o tamanho do arquivo
Embora pareça que 13.500 bytes não sejam muito para 300 células, talvez possamos fazer com uma quantidade menor. No final, temos controle total sobre como os dados são serializados. Vamos ver se existe uma maneira mais compacta de armazená-los.Redução numérica do intervalo
Diferentes níveis e índices de células são armazenados como um número inteiro. No entanto, eles usam apenas um pequeno intervalo de valores. Cada um deles permanecerá definitivamente na faixa de 0 a 255. Isso significa que apenas o primeiro byte de cada número inteiro será usado. Os três restantes sempre serão zero. Não faz sentido armazenar esses bytes vazios. Podemos descartá-los escrevendo número inteiro em byte antes de gravar no fluxo. writer.Write((byte)terrainTypeIndex); writer.Write((byte)elevation); writer.Write((byte)waterLevel); writer.Write((byte)urbanLevel); writer.Write((byte)farmLevel); writer.Write((byte)plantLevel); writer.Write((byte)specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write((byte)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((byte)outgoingRiver);
Agora, para retornar esses números, temos que usar BinaryReader.ReadByte
. A conversão de byte para inteiro é feita implicitamente, por isso não precisamos adicionar conversões explícitas. terrainTypeIndex = reader.ReadByte(); elevation = reader.ReadByte(); RefreshPosition(); waterLevel = reader.ReadByte(); urbanLevel = reader.ReadByte(); farmLevel = reader.ReadByte(); plantLevel = reader.ReadByte(); specialIndex = reader.ReadByte(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadByte(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadByte();
Então, nos livramos de três bytes por número inteiro, o que economiza 27 bytes por célula. Agora gastamos 18 bytes por célula e apenas 5.400 bytes por 300 células.Vale a pena notar que os dados antigos do cartão se tornam sem sentido nesse estágio. Ao carregar o salvamento antigo, os dados são misturados e obtemos células confusas. Isso ocorre porque agora estamos lendo menos dados. Se lermos mais dados do que antes, obteremos um erro ao tentar ler além do final do arquivo.A incapacidade de processar dados antigos nos convém, porque estamos determinando o formato. Porém, quando decidimos o formato de salvamento, precisamos garantir que o código futuro sempre possa lê-lo. Mesmo se mudarmos o formato, o ideal é que ainda possamos ler o formato antigo.River Byte Union
Nesta fase, usamos quatro bytes para armazenar dados do rio, dois por direção. Para cada direção, armazenamos a presença do rio e a direção em que ele flui.Pareceóbvio que não precisamos armazenar a direção do rio, se não estiver. Isso significa que as células sem um rio precisam de dois bytes a menos. De fato, um byte na direção do rio será suficiente para nós, independentemente de sua existência.Temos seis direções possíveis, que são armazenadas como números no intervalo de 0 a 5. Três bits são suficientes para isso, porque na forma binária os números de 0 a 5 se parecem com 000, 001, 010, 011, 100, 101 e 110. Ou seja, mais um byte permanece sem uso de mais cinco bits. Podemos usar um deles para indicar se existe um rio. Por exemplo, você pode usar o oitavo bit, correspondente ao número 128.Para fazer isso, adicionaremos 128 a ele antes de converter a direção em bytes. Ou seja, se tivermos um rio fluindo para o noroeste, escreveremos 133, que na forma binária é 10000101. E se não houver rio, simplesmente escrevemos um byte zero.Ao mesmo tempo, mais quatro bits permanecem sem uso, mas isso é normal. Podemos combinar as duas direções do rio em um byte, mas isso já será muito confuso.
Para decodificar dados do rio, primeiro precisamos ler o byte de volta. Se seu valor não for menor que 128, isso significa que existe um rio. Para obter sua direção, subtraia 128 e converta para HexDirection
.
Como resultado, obtivemos 16 bytes por célula. A melhoria parece não ser grande, mas esse é um daqueles truques usados para reduzir o tamanho dos dados binários.Salvar estradas em um byte
Podemos usar um truque semelhante para compactar dados da estrada. Temos seis valores booleanos que podem ser armazenados nos primeiros seis bits de um byte. Ou seja, cada direção da estrada é representada por um número que é uma potência de dois. São 1, 2, 4, 8, 16 e 32, ou na forma binária 1, 10, 100, 1000, 10000 e 100000.Para criar um byte finalizado, precisamos definir os bits que correspondem às direções usadas das estradas. Para obter a direção certa, podemos usar o operador <<
. Em seguida, combine-os usando o operador OR bit a bit. Por exemplo, se a primeira, segunda, terceira e sexta estradas forem usadas, o byte finalizado será 100111. int roadFlags = 0; for (int i = 0; i < roads.Length; i++) {
Como o << funciona?. integer . . integer . , . 1 << n
2 n , .
Para recuperar o valor booleano da estrada, é necessário verificar se o bit está definido. Nesse caso, mascare todos os outros bits usando o operador AND bit a bit com o número apropriado. Se o resultado não for igual a zero, o bit é definido e a estrada existe. int roadFlags = reader.ReadByte(); for (int i = 0; i < roads.Length; i++) { roads[i] = (roadFlags & (1 << i)) != 0; }
Depois de espremer seis bytes em um, recebemos 11 bytes por célula. Com 300 células, isso é apenas 3.300 bytes. Ou seja, depois de trabalhar um pouco com bytes, reduzimos o tamanho do arquivo em 75%.Preparando-se para o futuro
Antes de declarar nosso formato de salvamento completo, adicionamos mais um detalhe. Antes de salvar os dados do mapa, forçaremos a HexMapEditor
escrever um número inteiro zero. public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(0); hexGrid.Save(writer); } }
Isso adicionará quatro bytes vazios ao início de nossos dados. Ou seja, antes de carregar o cartão, precisamos ler esses quatro bytes. public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { reader.ReadInt32(); hexGrid.Load(reader); } }
Embora esses bytes sejam inúteis até agora, eles são usados como um cabeçalho que fornecerá compatibilidade com versões anteriores no futuro. Se não tivéssemos adicionado esses bytes nulos, o conteúdo dos primeiros bytes dependeria da primeira célula do mapa. Portanto, no futuro, seria mais difícil descobrir com qual versão do formato de salvamento estamos lidando. Agora podemos apenas verificar os quatro primeiros bytes. Se eles estiverem vazios, então estamos lidando com uma versão do formato 0. Nas versões futuras, será possível adicionar outra coisa lá.Ou seja, se o título for diferente de zero, estamos lidando com uma versão desconhecida. Como não conseguimos descobrir quais dados existem, devemos nos recusar a baixar o mapa. using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader); } else { Debug.LogWarning("Unknown map format " + header); } }
unitypackageParte 13: gerenciamento de cartões
- Criamos novas cartas no modo Play.
- Adicione suporte para vários tamanhos de cartão.
- Adicione o tamanho do mapa aos dados salvos.
- Salve e carregue mapas arbitrários.
- Exiba uma lista de cartões.
Nesta parte, adicionaremos suporte para vários tamanhos de cartões, além de salvar arquivos diferentes.A partir desta parte, os tutoriais serão criados no Unity 5.5.0.O início da biblioteca de mapas.Criar novos mapas
Até esse ponto, criamos a grade hexagonal apenas uma vez - ao carregar a cena. Agora tornaremos possível iniciar um novo mapa a qualquer momento. O novo cartão simplesmente substituirá o atual.No Desperta HexGrid
, algumas métricas são inicializadas e, em seguida, o número de células é determinado e os fragmentos e células necessários são criados. Criando um novo conjunto de fragmentos e células, criamos um novo mapa. Vamos dividir HexGrid.Awake
em duas partes - o código fonte de inicialização e o método geral CreateMap
. void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(); } public void CreateMap () { cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); }
Adicione um botão na interface do usuário para criar um novo mapa. Fiz grande e coloquei sob os botões salvar e carregar.Novo botão de mapa.Vamos conectar o evento On Click deste botão ao método do CreateMap
nosso objeto HexGrid
. Ou seja, não passaremos pelo Editor de mapa hexadecimal , mas chamaremos diretamente o método de objeto Hex Grid .Crie um mapa clicando em.Limpando dados antigos
Agora, quando você clica no botão Novo Mapa , um novo conjunto de fragmentos e células será criado. No entanto, os antigos não são excluídos automaticamente. Portanto, como resultado, obtemos várias malhas de mapas sobrepostas umas às outras. Para evitar isso, primeiro precisamos nos livrar de objetos antigos. Isso pode ser feito destruindo todos os fragmentos atuais no início CreateMap
. public void CreateMap () { if (chunks != null) { for (int i = 0; i < chunks.Length; i++) { Destroy(chunks[i].gameObject); } } … }
Podemos reutilizar objetos existentes?, . , . , — , .
É possível destruir elementos filho como este em um loop?Claro. .
Especifique o tamanho nas células em vez de fragmentos
Enquanto definimos o tamanho do mapa através dos campos chunkCountX
e do chunkCountZ
objeto HexGrid
. Mas será muito mais conveniente indicar o tamanho do mapa nas células. Ao mesmo tempo, podemos alterar o tamanho do fragmento no futuro sem alterar o tamanho dos cartões. Portanto, vamos trocar as funções dos campos número de células e número de fragmentos.
Isso levará a um erro de compilação, porque ele HexMapCamera
usa tamanhos de fragmento para limitar sua posição . Mude HexMapCamera.ClampPosition
para que ele use diretamente o número de células que ele ainda precisa. Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); return position; }
Um fragmento tem um tamanho de 5 por 5 células e os mapas por padrão têm um tamanho de 4 por 3 fragmentos. Portanto, para manter os cartões iguais, teremos que usar um tamanho de 20 por 15 células. E embora tenhamos atribuído valores padrão no código, o objeto de grade ainda não os utilizará automaticamente, porque os campos já existiam e tinham como padrão 0.Por padrão, o cartão tem um tamanho de 20 por 15.Tamanhos de cartões personalizados
O próximo passo será o suporte à criação de cartões de qualquer tamanho, não apenas do tamanho padrão. Para fazer isso, adicione HexGrid.CreateMap
X e Z aos parâmetros, que substituirão o número de células existente. Lá dentro, Awake
vamos chamá-los com o número atual de células. void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } public void CreateMap (int x, int z) { … cellCountX = x; cellCountZ = z; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); }
No entanto, isso funcionará corretamente apenas com o número de células que é um múltiplo do tamanho do fragmento. Caso contrário, a divisão inteira criará muito poucos fragmentos. Embora possamos adicionar suporte para fragmentos parcialmente preenchidos com células, vamos proibir o uso de tamanhos que não correspondem a fragmentos.Podemos usar o operador %
para calcular o restante da divisão do número de células pelo número de fragmentos. Se não for igual a zero, haverá uma discrepância e não criaremos um novo mapa. E enquanto fazemos isso, vamos adicionar proteção contra tamanhos zero e negativo. public void CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return; } … }
Novo menu do cartão
No estágio atual, o botão Novo Mapa não funciona mais, porque o método HexGrid.CreateMap
agora possui dois parâmetros. Não podemos conectar diretamente eventos do Unity a esses métodos. Além disso, para oferecer suporte a diferentes tamanhos de cartões, precisamos de alguns botões. Em vez de adicionar todos esses botões à interface principal, vamos criar um menu pop-up separado.Adicione uma nova tela à cena ( GameObject / UI / Canvas ). Usaremos as mesmas configurações da tela existente, exceto que sua ordem de classificação deve ser igual a 1. Graças a isso, ela estará no topo da interface do usuário do editor principal. Tornei a tela e o sistema de eventos um filho do novo objeto de interface do usuário para que a hierarquia da cena permanecesse limpa.Menu Tela Novo mapa.Adicione um painel ao menu Novo mapa que feche a tela inteira. É necessário escurecer o fundo e não permitir que o cursor interaja com todo o resto quando o menu estiver aberto. Dei a ele uma cor uniforme, limpando sua imagem de origem e defini (0, 0, 0, 200) como a cor .Configurações da imagem de fundo.Adicione uma barra de menus ao centro da tela, semelhante aos painéis do Hex Map Editor . Vamos criar uma etiqueta e botões claros para seus cartões pequenos, médios e grandes. Também adicionaremos um botão de cancelamento a ela, caso o jogador mude de idéia. Após terminar de criar o design, desative todo o Menu Novo Mapa .Novo menu de mapa.Para gerenciar o menu, crie um componente NewMapMenu
e adicione-o ao objeto New Map Menu . Para criar um novo mapa, precisamos acessar o objeto Hex Grid . Portanto, adicionamos um campo comum a ele e o conectamos. using UnityEngine; public class NewMapMenu : MonoBehaviour { public HexGrid hexGrid; }
Componente do novo menu de mapa.Abertura e fechamento
Podemos abrir e fechar o menu pop-up simplesmente ativando e desativando o objeto de tela. Vamos adicionar NewMapMenu
dois métodos comuns para fazer isso. public void Open () { gameObject.SetActive(true); } public void Close () { gameObject.SetActive(false); }
Agora conecte o botão New Map UI do editor ao método Open
no objeto New Map Menu .Abrindo o menu pressionando.Conecte também o botão Cancelar ao método Close
. Isso nos permitirá abrir e fechar o menu pop-up.Criar novos mapas
Para criar novos mapas, precisamos chamar o método no objeto Hex GridCreateMap
. Além disso, depois disso, precisamos fechar o menu pop-up. Adicione ao NewMapMenu
método que irá lidar com isso, levando em consideração um tamanho arbitrário. void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); Close(); }
Este método não deve ser geral, porque ainda não podemos conectá-lo diretamente aos eventos do botão. Em vez disso, crie um método por botão que chamará CreateMap
com o tamanho especificado. Para um mapa pequeno, usei um tamanho de 20 por 15, correspondendo ao tamanho padrão do mapa. Para a carta do meio, decidi dobrar esse tamanho, obtendo 40 por 30, e dobrar novamente para a carta grande. Conecte os botões com os métodos apropriados. public void CreateSmallMap () { CreateMap(20, 15); } public void CreateMediumMap () { CreateMap(40, 30); } public void CreateLargeMap () { CreateMap(80, 60); }
Bloqueio da câmera
Agora podemos usar o menu pop-up para criar novos mapas com três tamanhos diferentes! Tudo funciona bem, mas precisamos cuidar de um pequeno detalhe. Quando o menu Novo mapa está ativo, não podemos mais interagir com a interface do usuário do editor e editar células. No entanto, ainda podemos controlar a câmera. Idealmente, com o menu aberto, a câmera deve travar.Como temos apenas uma câmera, uma solução rápida e pragmática é simplesmente adicionar uma propriedade estática a ela Locked
. Para uso amplo, esta solução não é muito adequada, mas para a nossa interface simples é suficiente. Isso requer que rastreie a instância estática interna HexMapCamera
, que é definida quando a câmera Desperta. static HexMapCamera instance; … void Awake () { instance = this; swivel = transform.GetChild(0); stick = swivel.GetChild(0); }
Uma propriedade Locked
pode ser uma propriedade booleana estática simples apenas com um setter. Tudo o que faz é desativar a instância HexMapCamera
quando estiver bloqueada e ativá-la quando estiver desbloqueada. public static bool Locked { set { instance.enabled = !value; } }
Agora ele NewMapMenu.Open
pode bloquear a câmera e NewMapMenu.Close
- desbloqueá-la. public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; }
Manutenção da posição correta da câmera
Existe outro problema provável com a câmera. Ao criar um novo mapa menor que o atual, a câmera pode aparecer fora das bordas do mapa. Ela permanecerá lá até que o jogador tente mover a câmera. E só então será limitado pelos limites do novo mapa.Para resolver esse problema, podemos adicionar ao HexMapCamera
método estático ValidatePosition
. Chamar um método de AdjustPosition
instância com deslocamento zero forçará a câmera a se mover para as bordas do mapa. Se a câmera já estiver dentro das bordas do novo mapa, ela permanecerá no lugar. public static void ValidatePosition () { instance.AdjustPosition(0f, 0f); }
Chame o método dentro NewMapMenu.CreateMap
depois de criar um novo mapa. void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); HexMapCamera.ValidatePosition(); Close(); }
unitypackageSalvando o tamanho do mapa
Embora possamos criar cartões de tamanhos diferentes, isso não é levado em consideração ao salvar e carregar. Isso significa que carregar um mapa levará a um erro ou a um mapa incorreto se o tamanho do mapa atual não corresponder ao tamanho do mapa carregado.Para resolver esse problema, antes de carregar os dados da célula, precisamos criar um novo mapa do tamanho apropriado. Digamos que temos um pequeno mapa salvo. Nesse caso, tudo ficará bem se criarmos HexGrid.Load
um mapa de 20 por 15 no início . public void Load (BinaryReader reader) { CreateMap(20, 15); for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } }
Armazenamento de tamanho de cartão
Obviamente, podemos armazenar um cartão de qualquer tamanho. Portanto, uma solução generalizada será salvar o tamanho do mapa na frente dessas células. public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } }
Em seguida, podemos obter o tamanho real e usá-lo para criar um mapa com os tamanhos corretos. public void Load (BinaryReader reader) { CreateMap(reader.ReadInt32(), reader.ReadInt32()); … }
Como agora podemos carregar mapas de tamanhos diferentes, somos novamente confrontados com o problema da posição da câmera. Resolveremos isso verificando sua posição HexMapEditor.Load
após carregar o mapa. public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } }
Novo formato de arquivo
Embora essa abordagem funcione com cartões que manteremos no futuro, não funcionará com cartões antigos. E vice-versa - o código da parte anterior do tutorial não poderá carregar corretamente novos arquivos de mapa. Para distinguir entre formatos antigos e novos, aumentaremos o valor inteiro do cabeçalho. O formato antigo de salvar sem tamanho de mapa tinha a versão 0. O novo formato com tamanho de mapa terá a versão 1. Portanto, ao gravar, ele HexMapEditor.Save
deve gravar 1 em vez de 0. public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } }
A partir de agora, os cartões serão salvos como versão 1. Se tentarmos abri-los na montagem do tutorial anterior, eles se recusarão a carregar e relatar um formato de cartão desconhecido. De fato, isso acontecerá se já tentarmos carregar esse cartão. Você precisa alterar o método HexMapEditor.Load
para que ele aceite a nova versão. public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 1) { hexGrid.Load(reader); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } }
Compatibilidade com versões anteriores
De fato, se quisermos, ainda podemos baixar mapas da versão 0, supondo que todos tenham o mesmo tamanho 20 por 15. Ou seja, o título não precisa ser 1, também pode ser zero. Uma vez que cada versão requer sua própria abordagem, HexMapEditor.Load
é transmitir o método de cabeçalho HexGrid.Load
. if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); }
Inclua um HexGrid.Load
título no parâmetro e use-o para tomar decisões sobre outras ações. Se o cabeçalho não for menor que 1, você precisará ler os dados de tamanho do cartão. Caso contrário, usamos o tamanho antigo do cartão fixo de 20 por 15 e ignoramos a leitura dos dados do tamanho. public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } CreateMap(x, z); … }
versão do arquivo de mapa 0Verificação do tamanho do cartão
Assim como na criação de um novo mapa, é teoricamente possível que tenhamos de carregar um mapa incompatível com o tamanho do fragmento. Quando isso acontece, devemos interromper o download do cartão. HexGrid.CreateMap
já se recusa a criar um mapa e exibe um erro no console. Para dizer isso ao chamador do método, vamos retornar um booleano informando se o mapa foi criado. public bool CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return false; } … return true; }
Agora, HexGrid.Load
ele também pode interromper a execução quando a criação do mapa falha. public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } if (!CreateMap(x, z)) { return; } … }
Como o carregamento substitui todos os dados nas células existentes, não precisamos criar um novo mapa se um mapa do mesmo tamanho estiver carregado. Portanto, esta etapa pode ser ignorada. if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z)) { return; } }
unitypackageGerenciamento de arquivos
Podemos salvar e carregar cartões de tamanhos diferentes, mas sempre escreva e leia test.map . Agora vamos adicionar suporte para arquivos diferentes.Em vez de salvar ou carregar diretamente o mapa, usamos outro menu pop-up que fornece gerenciamento avançado de arquivos. Crie outra tela, como no menu Novo mapa , mas desta vez vamos chamá-lo de menu Salvar carregamento . Este menu salva e carrega mapas, dependendo do botão pressionado para abri-lo.Criaremos o design do menu Salvar carregamento .como se fosse um menu para salvar. Mais tarde, nós o transformaremos dinamicamente em um menu de inicialização. Como outro menu, ele deve ter um plano de fundo e uma barra de menus, um rótulo de menu e um botão de cancelamento. Em seguida, adicione uma exibição de rolagem ( GameObject / UI / Scroll View ) ao menu para exibir uma lista de arquivos. Abaixo, inserimos o campo de entrada ( GameObject / UI / Input Field ) para indicar os nomes dos novos cartões. Também precisamos de um botão de ação para salvar o mapa. E finalmente adicione um botão Excluir para excluir cartões desnecessários.Design Salvar menu de carregamento.Por padrão, a exibição de rolagem permite rolagem horizontal e vertical, mas precisamos apenas de uma lista com rolagem vertical. Portanto, desative a rolagem horizontal e retire o rolagem horizontal bar. Também configuramos o Tipo de movimento para fixar e desativar a inércia para tornar a lista mais restritiva.Opções da lista de arquivos. Removeremos ofilho Horizontal da Barra de Rolagem do objeto Lista de Arquivos , porque não precisamos dele. Em seguida, redimensione a barra de rolagem vertical para que ela atinja o final da lista.O texto do espaço reservado para o objeto Entrada de Nome pode ser alterado em seu espaço reservado filho . Usei texto descritivo, mas você pode deixá-lo em branco e se livrar do espaço reservado.Design do menu alterado.Concluímos o design e agora desativamos o menu para que por padrão ele fique oculto.Gerenciamento de menu
Para que o menu funcione, precisamos de outro script, neste caso - SaveLoadMenu
. Como NewMapMenu
, ele precisa de um link para a grade, bem como métodos Open
e Close
. using UnityEngine; public class SaveLoadMenu : MonoBehaviour { public HexGrid hexGrid; public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } }
Adicione esse componente ao SaveLoadMenu e atribua um link ao objeto de grade.Componente SaveLoadMenu.Um menu será aberto para salvar ou carregar. Para simplificar o trabalho, adicione um Open
parâmetro booleano ao método Determina se o menu deve estar no modo de salvamento. Seguiremos esse modo no campo para saber qual ação executar posteriormente. bool saveMode; public void Open (bool saveMode) { this.saveMode = saveMode; gameObject.SetActive(true); HexMapCamera.Locked = true; }
Agora combinar os botões Salvar e Carregar Objeto Hex Editor de mapa com o método Open
do objeto Salvar carregar o menu . Verifique o parâmetro booleano apenas para o botão Salvar .Abrindo o menu no modo de salvamento.Se você ainda não o fez, conecte o evento do botão Cancelar ao método Close
. Agora Salvar Menu de Carregamento pode ser aberta e fechada.Mudança na aparência
Criamos o menu como um menu para salvar, mas seu modo é determinado pelo botão pressionado para abrir. Precisamos alterar a aparência do menu, dependendo do modo. Em particular, precisamos alterar o rótulo do menu e o botão do botão de ação. Isso significa que precisaremos de links para essas tags. using UnityEngine; using UnityEngine.UI; public class SaveLoadMenu : MonoBehaviour { public Text menuLabel, actionButtonLabel; … }
Conexão com tags.Quando o menu é aberto no modo de salvamento, usamos os rótulos existentes, ou seja, Salvar Mapa para o menu e Salvar para o botão de ação. Caso contrário, estamos no modo de carregamento, ou seja, usamos o Load Map e o Load . public void Open (bool saveMode) { this.saveMode = saveMode; if (saveMode) { menuLabel.text = "Save Map"; actionButtonLabel.text = "Save"; } else { menuLabel.text = "Load Map"; actionButtonLabel.text = "Load"; } gameObject.SetActive(true); HexMapCamera.Locked = true; }
Digite o nome do cartão
Vamos deixar a lista de arquivos por enquanto. O usuário pode especificar o arquivo salvo ou baixado digitando o nome do cartão no campo de entrada. Para obter esses dados, precisamos de uma referência ao componente InputField
do objeto Name Input . public InputField nameInput;
Conexão ao campo de entrada.O usuário não precisa ser forçado a inserir o caminho completo para o arquivo de mapa. Basta o nome do cartão sem a extensão .map . Vamos adicionar um método que aceite a entrada do usuário e crie o caminho certo para ela. Isso não é possível quando a entrada está vazia, portanto, neste caso, retornaremos null
. using UnityEngine; using UnityEngine.UI; using System.IO; public class SaveLoadMenu : MonoBehaviour { … string GetSelectedPath () { string mapName = nameInput.text; if (mapName.Length == 0) { return null; } return Path.Combine(Application.persistentDataPath, mapName + ".map"); } }
O que acontece se o usuário digitar caracteres inválidos?, . , , .
Content Type . , - , . , , .
Salvando e carregando
Agora ele estará envolvido em salvar e carregar SaveLoadMenu
. Portanto, nós nos movemos os métodos Save
e Load
do HexMapEditor
no SaveLoadMenu
. Eles não precisam mais ser compartilhados e funcionarão com o parâmetro path em vez do caminho fixo. void Save (string path) {
Como agora estamos carregando arquivos arbitrários, seria bom verificar se o arquivo realmente existe e só então tentar lê-lo. Caso contrário, lançamos um erro e encerramos a operação. void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } … }
Agora adicione o método geral Action
. Começa com a obtenção do caminho selecionado pelo usuário. Se houver um caminho, salve ou carregue-o. Depois feche o menu. public void Action () { string path = GetSelectedPath(); if (path == null) { return; } if (saveMode) { Save(path); } else { Load(path); } Close(); }
Ao anexar um evento do botão de ação a esse método , podemos salvar e carregar usando nomes de mapas arbitrários. Como não redefinimos o campo de entrada, o nome selecionado permanecerá até o próximo salvamento ou carregamento. Isso é conveniente para salvar ou carregar de um arquivo várias vezes seguidas, para que não alteremos nada.Itens da lista de mapas
Em seguida, preencheremos a lista de arquivos com todos os cartões que estão no caminho de armazenamento de dados. Quando você clica em um dos itens da lista, ele será usado como texto na Entrada de Nome . Adicione um SaveLoadMenu
método geral para isso. public void SelectItem (string name) { nameInput.text = name; }
Precisamos de algo que é um item da lista. O botão de sempre serve. Crie-o e reduza a altura para 20 unidades, para que não ocupe muito espaço na vertical. Como não deve parecer um botão, limparemos o link Imagem de origem do componente Imagem . Nesse caso, ele ficará completamente branco. Além disso, garantiremos que o rótulo esteja alinhado à esquerda e que haja espaço entre o texto e o lado esquerdo do botão. Depois de terminar com o design do botão, o transformamos em uma pré-fabricada.Botão é um item da lista.Não podemos conectar diretamente o evento do botão ao Menu Novo Mapa , porque é uma pré-fabricada e ainda não existe na cena. Portanto, um item de menu precisa de um link para o menu para que ele possa chamar um método quando clicado SelectItem
. Ele também precisa acompanhar o nome do cartão que ele representa e definir seu texto. Vamos criar um pequeno componente para isso SaveLoadItem
. using UnityEngine; using UnityEngine.UI; public class SaveLoadItem : MonoBehaviour { public SaveLoadMenu menu; public string MapName { get { return mapName; } set { mapName = value; transform.GetChild(0).GetComponent<Text>().text = value; } } string mapName; public void Select () { menu.SelectItem(mapName); } }
Adicione um componente ao item de menu e faça o botão chamar seu método Select
.Componente de item.Preenchimento de lista
Para preencher a lista, você SaveLoadMenu
precisa de um link para Conteúdo dentro da janela de exibição do objeto Lista de Arquivos . Ele também precisa de um link para o item pré-fabricado. public RectTransform listContent; public SaveLoadItem itemPrefab;
Misture o conteúdo de uma lista e uma pré-fabricada.Usamos um novo método para preencher esta lista. A primeira etapa é identificar os arquivos de mapa existentes. Para uma disposição de caminhos para arquivos dentro do diretório, podemos usar o método Directory.GetFiles
. Este método possui um segundo parâmetro que permite filtrar arquivos. No nosso caso, apenas os arquivos correspondentes à máscara * .map são necessários . void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); }
Infelizmente, a ordem dos arquivos não é garantida. Para exibi-los em ordem alfabética, precisamos classificar a matriz com System.Array.Sort
. using UnityEngine; using UnityEngine.UI; using System; using System.IO; public class SaveLoadMenu : MonoBehaviour { … void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); Array.Sort(paths); } … }
Em seguida, criaremos instâncias pré-fabricadas para cada elemento da matriz. Ligue o item ao menu, defina o nome do mapa e torne-o filho do conteúdo da lista. Array.Sort(paths); for (int i = 0; i < paths.Length; i++) { SaveLoadItem item = Instantiate(itemPrefab); item.menu = this; item.MapName = paths[i]; item.transform.SetParent(listContent, false); }
Como ele Directory.GetFiles
retorna os caminhos completos para os arquivos, precisamos limpá-los. Felizmente, é exatamente isso que torna o método conveniente Path.GetFileNameWithoutExtension
. item.MapName = Path.GetFileNameWithoutExtension(paths[i]);
Antes de exibir o menu, precisamos preencher uma lista. E, como é provável que os arquivos sejam alterados, precisamos fazer isso sempre que abrirmos o menu. public void Open (bool saveMode) { … FillList(); gameObject.SetActive(true); HexMapCamera.Locked = true; }
Ao preencher novamente a lista, precisamos excluir todos os antigos antes de adicionar novos itens. void FillList () { for (int i = 0; i < listContent.childCount; i++) { Destroy(listContent.GetChild(i).gameObject); } … }
Itens sem arranjo.Disposição dos pontos
Agora a lista exibirá itens, mas eles se sobreporão e estarão em uma posição incorreta. Para transformá-los em uma lista vertical, adicione o componente Grupo de Layout Vertical ( Componente / Layout / Grupo de Layout Vertical ) ao objeto Conteúdo da lista . Para que a organização funcione corretamente, ative Largura do tamanho do controle infantil e expansão da força infantil . As duas opções de altura devem estar desabilitadas.Usando o grupo de layout vertical.Temos uma bela lista de itens. No entanto, o tamanho do conteúdo da lista não se ajusta ao número real de itens. Portanto, a barra de rolagem nunca altera o tamanho. Podemos forçar o Conteúdo a redimensionar automaticamente adicionando um componente Ajustador de tamanho de conteúdo ( Componente / Layout / Ajustador de tamanho de conteúdo ). Seu modo de ajuste vertical deve ser definido como Tamanho preferido .Usando o ajuste de tamanho de conteúdo.Agora, com um pequeno número de pontos, a barra de rolagem desaparecerá. E quando há muitos itens na lista que não cabem na janela de exibição, a barra de rolagem é exibida e possui um tamanho apropriado.Uma barra de rolagem é exibida.Exclusão do cartão
Agora podemos trabalhar convenientemente com muitos arquivos de mapa. No entanto, às vezes é necessário se livrar de alguns cartões. Para fazer isso, você pode usar o botão Excluir . Vamos criar um método para isso e fazer o botão chamá-lo. Se houver um caminho selecionado, simplesmente exclua-o com File.Delete
. public void Delete () { string path = GetSelectedPath(); if (path == null) { return; } File.Delete(path); }
Aqui também devemos verificar se estamos trabalhando com um arquivo realmente existente. Se não for esse o caso, não devemos tentar removê-lo, mas isso não leva a um erro. if (File.Exists(path)) { File.Delete(path); }
Depois de remover o cartão, não precisamos fechar o menu. Isso facilita a exclusão de vários arquivos por vez. No entanto, após a remoção, precisamos limpar a Entrada de nome e atualizar a lista de arquivos. if (File.Exists(path)) { File.Delete(path); } nameInput.text = ""; FillList();
unitypackageParte 14: texturas de relevo
- Use cores de vértice para criar um mapa de splat.
- Criando um ativo de textura de matriz.
- Adicionando índices de elevação a malhas.
- Transições entre texturas de relevo.
Até o momento, usamos cores sólidas para colorir cartões. Agora vamos aplicar a textura.Desenho de texturas.Uma mistura de três tipos
Embora as cores uniformes sejam claramente distinguíveis e bastante adequadas à tarefa, elas não parecem muito interessantes. O uso de texturas aumentará significativamente a atratividade dos mapas. Claro que, para isso, temos que misturar texturas, não apenas cores. No tutorial Rendering 3, Combining Textures, falei sobre como misturar várias texturas usando o mapa de splat. Em nossos mapas hexagonais, você pode usar uma abordagem semelhante.No tutorial Rendering 3apenas quatro texturas são misturadas e, com um mapa splat, podemos suportar até cinco texturas. No momento, usamos cinco cores diferentes, então isso é bastante adequado para nós. No entanto, mais tarde, podemos adicionar outros tipos. Portanto, é necessário suporte para um número arbitrário de tipos de alívio. Ao usar propriedades de textura definidas explicitamente, isso não é possível; portanto, é necessário usar uma matriz de texturas. Mais tarde vamos criá-lo.Ao usar matrizes de textura, precisamos, de alguma forma, dizer ao sombreador quais texturas misturar. A mistura mais difícil é necessária para triângulos angulares, que podem estar entre três células com seu próprio tipo de terreno. Portanto, precisamos misturar suporte entre os três tipos por triângulo.Usando cores de vértice como mapas do Splat
Supondo que possamos dizer quais texturas misturar, podemos usar cores de vértice para criar um mapa de splat para cada triângulo. Como em cada caso são usadas no máximo três texturas, precisamos apenas de três canais de cores. Vermelho representará a primeira textura, verde - a segunda e azul - a terceira.Mapa de triângulo Splat.A soma do mapa de splat do triângulo é sempre igual a um?Sim . . , (1, 0, 0) , (½, ½, 0) (⅓, ⅓, ⅓) .
Se um triângulo precisa de apenas uma textura, usamos apenas o primeiro canal. Ou seja, sua cor será completamente vermelha. No caso de mixar entre dois tipos diferentes, usamos o primeiro e o segundo canais. Ou seja, a cor do triângulo será uma mistura de vermelho e verde. E quando todos os três tipos forem encontrados, haverá uma mistura de vermelho, verde e azul.Três configurações de mapa splat.Usaremos essas configurações de mapa splat, independentemente de quais texturas realmente se misturam. Ou seja, o mapa splat sempre será o mesmo. Somente as texturas serão alteradas. Como fazer isso, descobriremos mais adiante.Precisamos mudar HexGridChunk
para criar esses mapas de splat, em vez de usar cores de célula. Como geralmente usamos três cores, criaremos campos estáticos para elas. static Color color1 = new Color(1f, 0f, 0f); static Color color2 = new Color(0f, 1f, 0f); static Color color3 = new Color(0f, 0f, 1f);
Centros celulares
Vamos começar substituindo a cor do centro das células por padrão. Nenhuma mistura é feita aqui, então usamos apenas a primeira cor, ou seja, vermelho. void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, color1); … }
Centros vermelhos de células.Os centros celulares agora ficam vermelhos. Todos eles usam a primeira das três texturas, independentemente da textura. Seus mapas de splat são os mesmos, independentemente da cor com a qual colorimos as células.Bairro do rio
Mudamos de segmento apenas dentro das células sem rios fluindo ao longo deles. Precisamos fazer o mesmo para os segmentos adjacentes aos rios. No nosso caso, isso é uma tira de costela e um leque de triângulos da costela. Aqui também apenas o vermelho é suficiente para nós. void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … }
Segmentos vermelhos adjacentes aos rios.Rivers
Em seguida, precisamos cuidar da geometria dos rios dentro das células. Todos eles também devem ficar vermelhos. Para começar, vamos dar uma olhada no começo e no fim dos rios. void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … }
E então a geometria que compõe as margens e o leito do rio. Agrupei as chamadas do método de cores para facilitar a leitura do código. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); terrain.AddTriangle(centerL, m.v1, m.v2);
Rios vermelhos ao longo das celas.Costelas
Todas as arestas são diferentes porque estão entre células que podem ter diferentes tipos de terreno. Usamos a primeira cor para o tipo de célula atual e a segunda cor para o tipo vizinho. Como resultado, o mapa de splat se tornará um gradiente vermelho-verde, mesmo se as duas células forem do mesmo tipo. Se as duas células usarem a mesma textura, ela se tornará uma mistura da mesma textura nos dois lados. void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad); } … }
Costelas verde-avermelhadas, exceto bordas.A transição acentuada entre vermelho e verde não causaria problemas?, , . . splat map, . .
, .
As arestas com as bordas são um pouco mais complicadas, porque possuem vértices adicionais. Felizmente, o código de interpolação existente funciona muito bem com cores de mapa splat. Basta usar a primeira e a segunda cores, não as cores das células do começo e do fim. void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); TriangulateEdgeStrip(begin, color1, e2, c2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(color1, color2, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, color2, hasRoad); }
Bordas verde-avermelhadas das costelas.Ângulos
Os ângulos das células são os mais difíceis porque precisam misturar três texturas diferentes. Usamos vermelho para o pico inferior, verde para a esquerda e azul para a direita. Vamos começar com os cantos de um triângulo. void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); }
Cantos vermelho-verde-azul, exceto para bordas.Aqui, podemos novamente usar o código de interpolação de cores existente para cantos com bordas. Apenas a interpolação é feita entre três e não duas cores. Primeiro, considere as bordas que não estão próximas dos penhascos. 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 c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); }
Bordas de canto vermelho-verde-azul, exceto bordas ao longo de falésias.Quando se trata de falésias, precisamos usar um método TriangulateBoundaryTriangle
. Este método recebeu as células inicial e esquerda como parâmetros. No entanto, agora precisamos das cores splat apropriadas, que podem variar dependendo da topologia. Portanto, substituímos esses parâmetros por cores. void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleColor(beginColor, c2, boundaryColor); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleColor(c1, c2, boundaryColor); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); }
Altere-o TriangulateCornerTerracesCliff
para que ele use as cores corretas. void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color3, b); TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } }
E faça o mesmo por TriangulateCornerCliffTerraces
. void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color2, b); TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } }
Mapa de relevo completo splat.unitypackageMatrizes de textura
Agora que nosso terreno possui um mapa de splat, podemos passar a coleção de texturas para o shader. Não podemos apenas atribuir um sombreador a uma matriz de texturas em C #, porque a matriz deve existir na memória da GPU como uma única entidade. Teremos que usar um objeto especial Texture2DArray
que é suportado no Unity desde a versão 5.4.Todas as GPUs suportam matrizes de textura?GPU , .
Unity .
- Direct3D 11/12 (Windows, Xbox One)
- OpenGL Core (Mac OS X, Linux)
- Metal (iOS, Mac OS X)
- OpenGL ES 3.0 (Android, iOS, WebGL 2.0)
- PlayStation 4
O mestre
Infelizmente, o suporte do Unity para matrizes de textura na versão 5.5 é mínimo. Não podemos apenas criar um recurso de matriz de textura e atribuir texturas a ele. Temos que fazer isso manualmente. Podemos criar uma matriz de texturas no modo Reproduzir ou criar um ativo no editor. Vamos criar um ativo.Por que criar um ativo?, Play . , .
, . Unity . , . , .
Para criar uma variedade de texturas, montaremos nosso próprio mestre. Crie um script TextureArrayWizard
e coloque-o dentro da pasta Editor . Em vez disso, MonoBehaviour
ele deve estender o tipo ScriptableWizard
do espaço para nome UnityEditor
. using UnityEditor; using UnityEngine; public class TextureArrayWizard : ScriptableWizard { }
Podemos abrir o assistente através de um método estático generalizado ScriptableWizard.DisplayWizard
. Seus parâmetros são os nomes da janela do assistente e seu botão de criação. Vamos chamar esse método em um método estático CreateWizard
. static void CreateWizard () { ScriptableWizard.DisplayWizard<TextureArrayWizard>( "Create Texture Array", "Create" ); }
Para acessar o assistente através do editor, precisamos adicionar esse método ao menu do Unity. Isto pode ser feito através da adição de um atributo para o método MenuItem
. Vamos adicioná-lo ao menu Ativos , e mais especificamente à matriz Ativos / Criar / Textura . [MenuItem("Assets/Create/Texture Array")] static void CreateWizard () { … }
Nosso assistente personalizado.Usando o novo item de menu, você pode abrir o menu pop-up do nosso assistente personalizado. Não é muito bonito, mas adequado para resolver o problema. No entanto, ainda está vazio. Para criar uma matriz de texturas, precisamos de uma matriz de texturas. Adicione um campo geral a ele para o mestre. A GUI padrão do assistente a exibe como um inspetor padrão. public Texture2D[] textures;
Mestre com texturas.Vamos criar algo
Quando você clica no botão Criar do assistente, ele desaparece. Além disso, a Unity reclama que não há método OnWizardCreate
. Esse é o método que é chamado quando o botão de criação é clicado, portanto, precisamos adicioná-lo ao assistente. void OnWizardCreate () { }
Aqui vamos criar nossa matriz de texturas. Pelo menos se o usuário adicionasse texturas ao mestre. Caso contrário, não há nada para criar e o trabalho precisa ser interrompido. void OnWizardCreate () { if (textures.Length == 0) { return; } }
A próxima etapa é solicitar o local para salvar o ativo da matriz de textura. Salve o arquivo de painel pode ser aberto por EditorUtility.SaveFilePanelInProject
. Seus parâmetros definem o nome do painel, o nome do arquivo padrão, a extensão e a descrição do arquivo. Matrizes de textura usam a extensão de arquivo do ativo geral . if (textures.Length == 0) { return; } EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" );
SaveFilePanelInProject
retorna o caminho do arquivo selecionado pelo usuário. Se o usuário clicar em cancelar neste painel, o caminho será uma sequência vazia. Portanto, neste caso, devemos interromper o trabalho. string path = EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); if (path.Length == 0) { return; }
Criando uma matriz de texturas
Se tivermos o caminho certo, podemos seguir em frente e criar um novo objeto Texture2DArray
. Seu método construtor requer a especificação da largura e altura da textura, o comprimento da matriz, o formato das texturas e a necessidade de texturas mip. Esses parâmetros devem ser os mesmos para todas as texturas na matriz. Para configurar o objeto, usamos a primeira textura. O usuário deve verificar se todas as texturas têm o mesmo formato. if (path.Length == 0) { return; } Texture2D t = textures[0]; Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 );
Como a matriz de textura é um único recurso da GPU, ela usa os mesmos modos de filtragem e dobra para todas as texturas. Aqui, novamente usamos a primeira textura para configurar tudo. Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 ); textureArray.anisoLevel = t.anisoLevel; textureArray.filterMode = t.filterMode; textureArray.wrapMode = t.wrapMode;
Agora podemos copiar a textura de uma matriz usando o método Graphics.CopyTexture
. O método copia dados brutos de textura, um nível mip por vez. Portanto, precisamos percorrer todas as texturas e seus níveis de mip. Os parâmetros do método são dois conjuntos que consistem em um recurso de textura, um índice e um nível mip. Como as texturas originais não são matrizes, seu índice é sempre zero. textureArray.wrapMode = t.wrapMode; for (int i = 0; i < textures.Length; i++) { for (int m = 0; m < t.mipmapCount; m++) { Graphics.CopyTexture(textures[i], 0, m, textureArray, i, m); } }
Nesta fase, temos na memória a matriz correta de texturas, mas ainda não é um ativo. O passo final será chamar AssetDatabase.CreateAsset
com a matriz e seu caminho. Nesse caso, os dados serão gravados em um arquivo em nosso projeto e aparecerão na janela do projeto. for (int i = 0; i < textures.Length; i++) { … } AssetDatabase.CreateAsset(textureArray, path);
Texturas
Para criar uma variedade real de texturas, precisamos das texturas originais. Aqui estão cinco texturas que correspondem às cores que usamos até agora. Amarelo se torna areia, verde se torna grama, azul se torna terra, laranja se torna pedra e branco se torna neve.Texturas de areia, grama, terra, pedra e neve.Observe que essas texturas não são fotografias desse relevo. Estes são os padrões pseudo-aleatórios fáceis que eu criei usando o NumberFlow . Eu me esforcei para criar tipos e detalhes de relevo reconhecíveis que não entrem em conflito com o relevo poligonal abstrato. O fotorrealismo acabou sendo inadequado para isso. Além disso, embora os padrões acrescentem variabilidade, existem poucos recursos distintos que tornariam as repetições imediatamente perceptíveis.Adicione essas texturas à matriz principal, certificando-se de que a ordem delas corresponda às cores. Ou seja, primeiro areia, depois grama, terra, pedra e finalmente neve.Criando uma matriz de texturas.Após criar o ativo da matriz de textura, selecione-o e examine-o no inspetor.Inspetor de matriz de textura.Essa é a exibição mais simples de uma parte dos dados da matriz de textura. Observe que há uma opção É legível que está ativada inicialmente. Como não precisamos ler dados de pixel da matriz, desative-os. Não podemos fazer isso no assistente, porque não existem Texture2DArray
métodos ou propriedades para acessar esse parâmetro.(No Unity 5.6, existe um erro que estraga as matrizes de textura em montagens em várias plataformas. Você pode contorná-lo sem desabilitar É legível .)Também é importante notar que existe um campo Espaço de corao qual é atribuído o valor 1. Isso significa que as texturas são assumidas como estando no espaço gama, o que é verdadeiro. Se eles deveriam estar no espaço linear, o campo precisava receber o valor 0. Na verdade, o designer Texture2DArray
possui um parâmetro adicional para especificar o espaço de cores, mas Texture2D
não mostra se está no espaço linear ou não; portanto, em qualquer caso, é necessário definir valor manualmente.Shader
Agora que temos uma variedade de texturas, precisamos ensinar ao shader como trabalhar com ele. Por enquanto, usamos o sombreador VertexColors para renderizar o terreno . Como agora usaremos texturas em vez de cores, renomeie-o para Terrain . Em seguida, transformamos seu parâmetro _MainTex em uma matriz de texturas e atribuímos a ele um ativo. Shader "Custom/Terrain" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } … }
Material de alívio com uma variedade de texturas.Para habilitar matrizes de textura em todas as plataformas que as suportam, você precisa aumentar o nível de destino do shader de 3.0 para 3.5. #pragma target 3.5
Como a variável _MainTex
agora se refere a uma matriz de texturas, precisamos alterar seu tipo. O tipo depende da plataforma de destino e a macro cuidará disso UNITY_DECLARE_TEX2DARRAY
.
Como em outros shaders, para provar a textura do relevo, precisamos das coordenadas do mundo XZ. Portanto, adicionaremos uma posição no mundo à estrutura de entrada do shader de superfície. Também excluímos as coordenadas UV padrão, porque não precisamos delas. struct Input {
Para provar uma variedade de texturas, precisamos usar uma macro UNITY_SAMPLE_TEX2DARRAY
. Para provar uma matriz, ela precisa de três coordenadas. Os dois primeiros são coordenadas UV regulares. Usaremos as coordenadas do mundo XZ na escala de 0,02. Portanto, obtemos uma boa resolução de textura com ampliação total. As texturas serão repetidas aproximadamente a cada quatro células.A terceira coordenada é usada como o índice da matriz de texturas, como em uma matriz regular. Como as coordenadas são flutuantes, antes da indexação, a matriz da GPU as arredonda. Até que saibamos que textura é necessária, vamos sempre usar a primeira. Além disso, a cor do vértice não afetará o resultado final, porque é um mapa de splat. void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.worldPos.xz * 0.02; fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, float3(uv, 0)); Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Tudo se tornou areia.unitypackageSeleção de textura
Precisamos de um mapa de relevo que mistura os três tipos em um triângulo. Temos uma variedade de texturas com uma textura para cada tipo de terreno. Temos um shader que mostra uma variedade de texturas. Mas, por enquanto, não temos como dizer ao sombreador quais texturas escolher para cada triângulo.Como cada triângulo combina até três tipos, precisamos associar três índices a cada triângulo. Como não podemos armazenar informações para triângulos, precisamos armazenar índices para vértices. Todos os três vértices do triângulo simplesmente armazenam os mesmos índices da cor sólida.Dados de malhas
Podemos usar um dos conjuntos da malha UV para armazenar índices. Como três índices são armazenados em cada vértice, os conjuntos de 2D UV existentes não serão suficientes. Felizmente, os conjuntos de UV podem conter até quatro coordenadas. Portanto, adicionamos à HexMesh
segunda lista Vector3
, à qual nos referiremos como tipos de alívio. public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; public bool useTerrainTypes; [NonSerialized] List<Vector3> vertices, terrainTypes;
Ative os tipos de terreno para o filho do Terreno da pré-fabricada Hex Grid Chunk .Usamos tipos de alívio.Se necessário, faremos outra lista Vector3
para os tipos de relevo durante a limpeza da malha. public void Clear () { … if (useTerrainTypes) { terrainTypes = ListPool<Vector3>.Get(); } triangles = ListPool<int>.Get(); }
No processo de aplicação dos dados da malha, salvamos os tipos de relevo no terceiro conjunto de UV. Por isso, eles não entrarão em conflito com outros dois conjuntos, se decidirmos usá-los juntos. public void Apply () { … if (useTerrainTypes) { hexMesh.SetUVs(2, terrainTypes); ListPool<Vector3>.Add(terrainTypes); } hexMesh.SetTriangles(triangles, 0); … }
Para definir os tipos de relevo do triângulo, usaremos Vector3
. Como os mesmos são iguais para todo o triângulo, apenas adicionamos os mesmos dados três vezes. public void AddTriangleTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); }
Misturar em quad funciona da mesma maneira. Todos os quatro vértices são do mesmo tipo. public void AddQuadTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); }
Fãs de Triângulos de Costelas
Agora precisamos adicionar tipos aos dados da malha HexGridChunk
. Vamos começar com TriangulateEdgeFan
. Primeiro, para melhor legibilidade, vamos separar as chamadas para os métodos de vértice e cor. Lembre-se de que a cada chamada para esse método, a passamos para ele color1
, para que possamos usar essa cor diretamente e não aplicar o parâmetro. void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { terrain.AddTriangle(center, edge.v1, edge.v2);
Após as cores, adicionamos tipos de relevo. Como os tipos no triângulo podem ser diferentes, esse deve ser um parâmetro que substitui a cor. Use este tipo simples para criar Vector3
. Apenas os quatro primeiros canais são importantes para nós, porque nesse caso o mapa splat é sempre vermelho. Como todos os três componentes do vetor precisam ser atribuídos, vamos atribuir a eles um tipo. void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float type) { … Vector3 types; types.x = types.y = types.z = type; terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); }
Agora, precisamos alterar todas as chamadas para esse método, substituindo o argumento de cores por um índice do tipo de terreno da célula. Vnesom esta mudança TriangulateWithoutRiver
, TriangulateAdjacentToRiver
e TriangulateWithRiverBeginOrEnd
.
Nesse ponto, quando você inicia o modo de reprodução, serão exibidos erros informando que os terceiros conjuntos de malhas UV estão fora dos limites. Isso aconteceu porque ainda não adicionamos tipos de relevo a cada triângulo e quad. Então, vamos continuar a mudar HexGridChunk
.Listras de costela
Agora, ao criar uma faixa de aresta, precisamos saber que tipos de terreno existem nos dois lados. Portanto, nós os adicionamos como parâmetros e, em seguida, criamos um vetor de tipos cujos dois canais são atribuídos a esses tipos. O terceiro canal não é importante, apenas o iguale ao primeiro. Depois de adicionar as cores, adicione os tipos ao quad. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, float type1, EdgeVertices e2, Color c2, float type2, 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); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); Vector3 types; types.x = types.z = type1; types.y = type2; terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } }
Agora precisamos mudar os desafios TriangulateEdgeStrip
. Primeiro TriangulateAdjacentToRiver
, TriangulateWithRiverBeginOrEnd
e TriangulateWithRiver
deve usar o tipo de célula para ambos os lados da tira da costela.
Em seguida, o caso mais simples de uma aresta TriangulateConnection
deve usar o tipo de célula para a aresta mais próxima e o tipo vizinho para a aresta mais distante. Eles podem ser iguais ou diferentes. void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else {
O mesmo se aplica TriangulateEdgeTerraces
àquilo que desencadeia três vezes TriangulateEdgeStrip
. Os tipos para as bordas são os mesmos. void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); float t1 = beginCell.TerrainTypeIndex; float t2 = endCell.TerrainTypeIndex; TriangulateEdgeStrip(begin, color1, t1, e2, c2, t2, hasRoad); for (int i = 2; i < HexMetrics.terraceSteps; i++) { EdgeVertices e1 = e2; Color c1 = c2; e2 = EdgeVertices.TerraceLerp(begin, end, i); c2 = HexMetrics.TerraceLerp(color1, color2, i); TriangulateEdgeStrip(e1, c1, t1, e2, c2, t2, hasRoad); } TriangulateEdgeStrip(e2, c2, t1, end, color2, t2, hasRoad); }
Ângulos
O caso mais simples de um ângulo é um triângulo simples. A célula inferior transfere o primeiro tipo, o esquerdo o segundo e o direito o terceiro. Usando-os, crie um vetor de tipos e adicione-o ao triângulo. void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); Vector3 types; types.x = bottomCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); }
Usamos a mesma abordagem TriangulateCornerTerraces
, apenas aqui criamos um grupo de quad-s. 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 c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); terrain.AddQuadTerrainTypes(types); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); terrain.AddQuadTerrainTypes(types); }
Ao misturar bordas e falésias, precisamos usar TriangulateBoundaryTriangle
. Apenas dê a ele um parâmetro de vetor de tipo e adicione-o a todos os seus triângulos. void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor, Vector3 types ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleColor(beginColor, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleColor(c1, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); terrain.AddTriangleTerrainTypes(types); }
A TriangulateCornerTerracesCliff
criação de vector com base nos tipos de células transmitidas. Em seguida, adicione-o a um triângulo e passe TriangulateBoundaryTriangle
. 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 boundaryColor = Color.Lerp(color1, color3, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } }
O mesmo vale para 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 boundaryColor = Color.Lerp(color1, color2, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } }
Rivers
O último método para mudar é este TriangulateWithRiver
. Como aqui estamos no centro da célula, estamos lidando apenas com o tipo da célula atual. Portanto, crie um vetor para ele e adicione-o a triângulos e quad-s. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); Vector3 types; types.x = types.y = types.z = cell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); … }
Tipo de mistura
Nesta fase, as malhas contêm os índices de elevação necessários. Tudo o que resta para nós é forçar o shader Terrain a usá-los. Para que os índices caiam no shader de fragmento, primeiro precisamos passá-los pelo shader de vértice. Podemos fazer isso em nossa própria função de vértice, como fizemos no sombreador do estuário . Nesse caso, adicionamos um campo à estrutura de entrada float3 terrain
e copiamos para ela v.texcoord2.xyz
. #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 … struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; }; void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); data.terrain = v.texcoord2.xyz; }
Precisamos amostrar a matriz de textura três vezes por fragmento. Portanto, vamos criar uma função conveniente para criar coordenadas de textura, amostrar uma matriz e modular uma amostra com um mapa de splat para um índice. 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]; } void surf (Input IN, inout SurfaceOutputStandard o) { … }
Podemos trabalhar com um vetor como uma matriz?Sim - color[0]
color.r
. color[1]
color.g
, .
Usando esta função, podemos simplesmente amostrar a matriz de textura três vezes e combinar os resultados. void surf (Input IN, inout SurfaceOutputStandard o) { // float2 uv = IN.worldPos.xz * 0.02; fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); o.Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Relevo texturizado.Agora podemos pintar o relevo com texturas. Eles se misturam como cores sólidas. Como usamos as coordenadas mundiais como coordenadas UV, elas não mudam com a altura. Como resultado, ao longo de penhascos afiados, as texturas são esticadas. Se as texturas forem bastante neutras e muito variáveis, os resultados serão aceitáveis. Caso contrário, temos grandes estrias feias. Você pode tentar ocultá-lo com geometria ou textura adicional de falésias, mas no tutorial não faremos isso.Varrer
Agora, quando usarmos texturas em vez de cores, será lógico alterar o painel do editor. Podemos criar uma interface bonita que pode até exibir texturas de relevo, mas vou me concentrar nas abreviações que correspondem ao estilo do esquema existente.Opções de alívio.Além disso, a HexCell
propriedade color não é mais necessária, portanto exclua-a.
Você HexGrid
também pode remover uma matriz de cores e código associado.
Finalmente, também não é necessário um conjunto de cores HexMetrics
.
unitypackageParte 15: distâncias
- Exiba as linhas da grade.
- Alterne entre os modos de edição e navegação.
- Calcule a distância entre as células.
- Nós encontramos maneiras de contornar obstáculos.
- Levamos em conta os custos variáveis da mudança.
Tendo criado mapas de alta qualidade, iniciaremos a navegação.O caminho mais curto nem sempre é reto.Grade de exibição
A navegação no mapa é realizada movendo de célula para célula. Para chegar a algum lugar, você precisa passar por uma série de células. Para facilitar a estimativa de distâncias, vamos adicionar a opção de exibir a grade hexagonal na qual nosso mapa se baseia.Textura de malha
Apesar das irregularidades da malha do mapa, a malha subjacente é perfeitamente plana. Podemos mostrar isso projetando um padrão de grade em um mapa. Isso pode ser conseguido usando uma textura de malha repetida.Repetindo a textura da malha.A textura mostrada acima contém uma pequena parte da grade hexagonal cobrindo 2 por 2 células. Esta área é retangular, não quadrada. Como a textura em si é um quadrado, o padrão parece esticado. Ao amostrar, precisamos compensar isso.Projeção em grade
Para projetar um padrão de malha, precisamos adicionar uma propriedade de textura ao shader Terrain . 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 }
Material de alívio com textura de malha.Prove a textura usando as coordenadas XZ do mundo e multiplique-a por albedo. Como as linhas de grade da textura são cinza, isso entrelaça o padrão no relevo. sampler2D _GridTex; … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); fixed4 grid = tex2D(_GridTex, IN.worldPos.xz); o.Albedo = c.rgb * grid * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Albedo multiplicado por malha fina.Precisamos escalar o padrão para que ele corresponda às células no mapa. A distância entre os centros das células vizinhas é 15, ela precisa ser dobrada para subir duas células. Ou seja, precisamos dividir as coordenadas da grade V por 30. O raio interno das células é 5√3 e, para mover duas células para a direita, precisamos de quatro vezes mais. Portanto, é necessário dividir as coordenadas da grade U por 20√3. float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); fixed4 grid = tex2D(_GridTex, gridUV);
O tamanho de malha correto.Agora as linhas da grade correspondem às células do mapa. Como texturas de relevo, eles ignoram a altura, de modo que as linhas serão esticadas ao longo dos penhascos.Projeção em células com altura.A deformação da malha geralmente não é tão ruim, especialmente quando se olha para um mapa a longa distância.Malha à distância.Inclusão de grade
Embora exibir uma grade seja conveniente, nem sempre é necessário. Por exemplo, você deve desativá-lo quando tirar uma captura de tela. Além disso, nem todo mundo prefere ver a grade constantemente. Então, vamos torná-lo opcional. Adicionaremos a diretiva multi_compile ao shader para criar opções com e sem uma grade. Para fazer isso, usamos a palavra-chave GRID_ON
. A compilação condicional de sombreador é descrita no tutorial Rendering 5, Multiple Lights . #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 #pragma multi_compile _ GRID_ON
Ao declarar uma variável, grid
primeiro atribuímos a ela um valor 1. Como resultado, a grade será desativada. Em seguida, amostraremos a textura da grade apenas para a variante com uma palavra-chave específica GRID_ON
. fixed4 grid = 1; #if defined(GRID_ON) float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); grid = tex2D(_GridTex, gridUV); #endif o.Albedo = c.rgb * grid * _Color;
Como a palavra-chave GRID_ON
não está incluída no sombreador de terreno, a grade desaparecerá. Para habilitá-lo novamente, adicionaremos uma opção à interface do usuário do editor de mapas. Para tornar isso possível, HexMapEditor
preciso obter um link para o material do terreno e um método para ativar ou desativar a palavra-chave GRID_ON
. public Material terrainMaterial; … public void ShowGrid (bool visible) { if (visible) { terrainMaterial.EnableKeyword("GRID_ON"); } else { terrainMaterial.DisableKeyword("GRID_ON"); } }
Editor de hexágonos de março com referência ao material.Adicione um comutador de grade à interface do usuário e conecte-o ao método ShowGrid
.Interruptor de grade.Salvar estado
Agora, no modo Play, podemos mudar a exibição da grade. No primeiro teste, a grade é inicialmente desligada e fica visível quando ligamos o interruptor. Quando você o desliga, a grade desaparece novamente. No entanto, se sairmos do modo de reprodução quando a grade estiver visível, na próxima vez que você iniciar o modo de reprodução, ele será ativado novamente, embora o interruptor esteja desligado.Isso ocorre porque estamos alterando a palavra-chave para o material geral do terreno . Como estamos editando o ativo do material, a alteração é salva no editor do Unity. Não será salvo na montagem.Para sempre iniciar o jogo sem uma grade, desabilitaremos a palavra-chave GRID_ON
Desperta HexMapEditor
. void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); }
unitypackageModo de edição
Se queremos controlar o movimento no mapa, precisamos interagir com ele. No mínimo, precisamos selecionar a célula como o ponto de partida do caminho. Mas quando você clica em uma célula, ela será editada. Podemos desativar todas as opções de edição manualmente, mas isso é inconveniente. Além disso, não queremos que os cálculos de deslocamento sejam executados durante a edição do mapa. Então, vamos adicionar uma opção que determina se estamos no modo de edição.Interruptor de edição
Adicione ao HexMapEditor
campo booleano editMode
, bem como o método que o define. Em seguida, adicione outra opção à interface do usuário para controlá-la. Vamos começar com o modo de navegação, ou seja, o modo de edição será desativado por padrão. bool editMode; … public void SetEditMode (bool toggle) { editMode = toggle; }
Interruptor do modo de edição.Para realmente desativar a edição, faça a chamada EditCells
depender editMode
. void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } if (editMode) { EditCells(currentCell); } previousCell = currentCell; } else { previousCell = null; } }
Etiquetas de depuração
Até o momento, não temos unidades para percorrer o mapa. Em vez disso, visualizamos as distâncias de movimento. Para fazer isso, você pode usar rótulos de células existentes. Portanto, os tornaremos visíveis quando o modo de edição estiver desativado. public void SetEditMode (bool toggle) { editMode = toggle; hexGrid.ShowUI(!toggle); }
Como começamos com o modo de navegação, os rótulos padrão devem estar ativados. Atualmente os HexGridChunk.Awake
desativa, mas ele não deve mais fazer isso. void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
Rótulos de coordenadas.As coordenadas das células agora ficam visíveis imediatamente após o início do modo Play. Mas não precisamos de coordenadas, usamos rótulos para exibir distâncias. Como isso requer apenas um número por célula, você pode aumentar o tamanho da fonte para que eles possam ser lidos melhor. Altere a pré-fabricada do Hex Cell Label para que ele use fonte em negrito com tamanho 8.Tags com tamanho de fonte em negrito 8.Agora, depois de iniciar o modo Play, veremos tags grandes. Somente as primeiras coordenadas da célula são visíveis, o restante não é colocado no rótulo.Tags grandes.Como não precisamos mais das coordenadas, excluiremos o HexGrid.CreateCell
valor na atribuição label.text
. void CreateCell (int x, int z, int i) { … Text label = Instantiate<Text>(cellLabelPrefab); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z);
Você também pode remover a opção Labels e seu método associado da interface do usuário HexMapEditor.ShowUI
.
A mudança de método não existe mais.unitypackageEncontrando distâncias
Agora que temos o modo de navegação marcado, podemos começar a exibir distâncias. Selecionaremos uma célula e, em seguida, exibiremos a distância dessa célula para todas as células no mapa.Exibição distância
Para rastrear a distância até a célula, adicione ao HexCell
campo inteiro distance
. Isso indicará a distância entre esta célula e a selecionada. Portanto, para a célula selecionada em si, será zero, para o vizinho imediato é 1 e assim por diante. int distance;
Quando a distância é definida, devemos atualizar o rótulo da célula para exibir seu valor. HexCell
tem uma referência ao RectTransform
objeto de interface do usuário. Precisamos ligar GetComponent<Text>
para ele para chegar ao celular. Considere o que Text
está no espaço UnityEngine.UI
para nome , portanto, use-o no início do script. void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance.ToString(); }
Não devemos manter um link direto para o componente Texto?, . , , , . , .
Vamos definir a propriedade geral para receber e definir a distância para a célula, além de atualizar seu rótulo. public int Distance { get { return distance; } set { distance = value; UpdateDistanceLabel(); } }
Adicione ao HexGrid
método geral FindDistancesTo
com o parâmetro cell. Por enquanto, simplesmente definiremos a distância zero para cada célula. public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = 0; } }
Se o modo de edição não estiver ativado, HexMapEditor.HandleInput
chamaremos um novo método com a célula atual. if (editMode) { EditCells(currentCell); } else { hexGrid.FindDistancesTo(currentCell); }
Distâncias entre coordenadas
Agora, no modo de navegação, depois de tocar em um deles, todas as células exibem zero. Mas, é claro, eles devem exibir a verdadeira distância da célula. Para calcular a distância até eles, podemos usar as coordenadas da célula. Portanto, suponha que ele HexCoordinates
tenha um método DistanceTo
e use-o HexGrid.FindDistancesTo
. public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } }
Agora adicione ao HexCoordinates
método DistanceTo
. Ele deve comparar suas próprias coordenadas com as de outro conjunto. Vamos começar apenas medindo X e subtrairemos as coordenadas X uma da outra. public int DistanceTo (HexCoordinates other) { return x - other.x; }
Como resultado, obtemos um deslocamento ao longo de X em relação à célula selecionada. Como as distâncias não podem ser negativas, é necessário retornar a diferença de coordenadas X módulo. return x < other.x ? other.x - x : x - other.x;
Distâncias ao longo de X.Portanto, só obtemos as distâncias corretas se considerarmos apenas uma dimensão. Mas existem três dimensões em uma grade de hexágonos. Então, vamos somar as distâncias para todas as três dimensões e ver o que isso nos dá. return (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z);
Soma das distâncias XYZ.Acontece que temos o dobro da distância. Ou seja, para obter a distância correta, esse valor deve ser dividido pela metade. return ((x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z)) / 2;
Distâncias reais.Por que a soma é igual ao dobro da distância?, . , (1, −3, 2). . , . . , . .
. unitypackageTrabalhe com obstáculos
As distâncias calculadas por nós correspondem aos caminhos mais curtos da célula selecionada para a outra célula. Não podemos encontrar um caminho mais curto. Mas é garantido que esses caminhos estejam corretos se a rota não bloquear nada. Falésias, água e outros obstáculos podem nos fazer girar. Talvez algumas células não possam ser alcançadas.Para encontrar uma maneira de contornar obstáculos, precisamos usar uma abordagem diferente em vez de simplesmente calcular a distância entre as coordenadas. Não podemos mais examinar cada célula individualmente. Teremos que procurar no mapa até encontrar todas as células que podem ser alcançadas.Visualização de pesquisa
A pesquisa de mapa é um processo iterativo. Para entender o que estamos fazendo, seria útil ver cada estágio da pesquisa. Podemos fazer isso transformando o algoritmo de pesquisa em uma rotina, para a qual precisamos de um espaço de pesquisa System.Collections
. A taxa de atualização de 60 iterações por segundo é pequena o suficiente para vermos o que está acontecendo, e a pesquisa em um pequeno mapa não demorou muito tempo. public void FindDistancesTo (HexCell cell) { StartCoroutine(Search(cell)); } IEnumerator Search (HexCell cell) { WaitForSeconds delay = new WaitForSeconds(1 / 60f); for (int i = 0; i < cells.Length; i++) { yield return delay; cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } }
Precisamos garantir que apenas uma pesquisa esteja ativa a qualquer momento. Portanto, antes de iniciar uma nova pesquisa, paramos todas as corotinas. public void FindDistancesTo (HexCell cell) { StopAllCoroutines(); StartCoroutine(Search(cell)); }
Além disso, precisamos concluir a pesquisa ao carregar um novo mapa. public void Load (BinaryReader reader, int header) { StopAllCoroutines(); … }
Primeira pesquisa de largura
Mesmo antes de iniciar a pesquisa, sabemos que a distância para a célula selecionada é zero. E, é claro, a distância para todos os seus vizinhos é 1, se puderem ser alcançados. Então podemos dar uma olhada em um desses vizinhos. Essa célula provavelmente tem seus próprios vizinhos que podem ser alcançados e para os quais a distância ainda não foi calculada. Nesse caso, a distância para esses vizinhos deve ser 2. Podemos repetir esse processo para todos os vizinhos a uma distância de 1. Depois disso, repetimos para todos os vizinhos a uma distância de 2. E assim por diante, até atingirmos todas as células.Ou seja, primeiro encontramos todas as células a uma distância de 1, depois encontramos tudo a uma distância de 2, depois a uma distância de 3 e assim por diante, até terminarmos. Isso garante que encontramos a menor distância para cada célula acessível. Esse algoritmo é chamado de busca pela primeira vez.Para que funcione, precisamos saber se já determinamos a distância da célula. Muitas vezes, para isso, as células são colocadas em uma coleção chamada conjunto pronto ou fechado. Mas podemos definir a distância da célula int.MaxValue
para indicar que ainda não a visitamos. Precisamos fazer isso para todas as células antes de realizar uma pesquisa. IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } … }
Você também pode usar isso para ocultar todas as células não visitadas, alterando HexCell.UpdateDistanceLabel
. Depois disso, iniciaremos cada pesquisa em um mapa em branco. void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance == int.MaxValue ? "" : distance.ToString(); }
Em seguida, precisamos rastrear as células que precisam ser visitadas e a ordem em que elas são visitadas. Essa coleção geralmente é chamada de borda ou conjunto aberto. Nós apenas precisamos processar as células na mesma ordem em que as encontramos. Para fazer isso, você pode usar a fila Queue
, que faz parte do espaço para nome System.Collections.Generic
. A célula selecionada será a primeira a ser colocada nessa fila e terá uma distância de 0. IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); Queue<HexCell> frontier = new Queue<HexCell>(); cell.Distance = 0; frontier.Enqueue(cell);
A partir deste momento, o algoritmo executa o loop enquanto houver algo na fila. A cada iteração, a célula da frente é recuperada da fila. frontier.Enqueue(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); }
Agora temos a célula atual, que pode estar a qualquer distância. Em seguida, precisamos adicionar todos os seus vizinhos à fila um passo além da célula selecionada. while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor != null) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } } }
Mas devemos adicionar apenas as células que ainda não receberam uma distância. if (neighbor != null && neighbor.Distance == int.MaxValue) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); }
Pesquisa ampla.Evite a água
Depois de garantir que a primeira pesquisa de largura encontre as distâncias corretas no mapa monótono, podemos começar a adicionar obstáculos. Isso pode ser feito recusando-se a adicionar células à fila se determinadas condições forem atendidas.De fato, já pulamos algumas células: aquelas que não existem e aquelas para as quais já indicamos a distância. Vamos reescrever o código para que, neste caso, pulemos explicitamente os vizinhos. for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); }
Também vamos pular todas as células que estão debaixo d'água. Isso significa que, ao procurar as distâncias mais curtas, consideramos apenas o movimento no solo. if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } if (neighbor.IsUnderwater) { continue; }
Distâncias sem se mover pela água.O algoritmo ainda encontra as distâncias mais curtas, mas agora evita toda a água. Portanto, as células subaquáticas nunca ganham distância, como áreas isoladas da terra. A célula subaquática somente recebe distância se for selecionada.Evitar falésias
Além disso, para determinar a possibilidade de visitar um vizinho, podemos usar o tipo de costela. Por exemplo, você pode fazer falésias bloquear o caminho. Se você permitir o movimento nas encostas, as células do outro lado do penhasco ainda poderão ser alcançadas, apenas em outros caminhos. Portanto, eles podem estar em distâncias muito diferentes. if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; }
Distâncias sem cruzar falésias.unitypackageDespesas de viagem
Podemos evitar células e bordas, mas essas opções são binárias. Pode-se imaginar que é mais fácil navegar em algumas direções do que em outras. Nesse caso, a distância é medida em trabalho ou tempo.Estradas rápidas
Será lógico que é mais fácil e rápido viajar nas estradas, portanto, vamos tornar a interseção das arestas com as estradas menos caras. Como usamos valores inteiros para definir a distância, deixaremos o custo de mover-se pelas estradas igual a 1, e o custo de atravessar outras arestas, aumentaremos para 10. Essa é uma grande diferença que nos permite ver imediatamente se obtemos os resultados certos. int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } neighbor.Distance = distance;
Estradas com distâncias erradas.Classificação da borda
Infelizmente, verifica-se que a primeira pesquisa não pode funcionar com custos variáveis de movimentação. Ele assume que as células são adicionadas à borda na ordem crescente da distância e, para nós, isso não é mais relevante. Precisamos de uma fila de prioridade, ou seja, uma fila que se classifique. Não há filas de prioridade padrão, porque você não pode programá-las de maneira que elas se ajustem a todas as situações.Podemos criar nossa própria fila de prioridades, mas vamos otimizá-la para o futuro tutorial. Por enquanto, simplesmente substituímos a fila por uma lista que terá um método Sort
. List<HexCell> frontier = new List<HexCell>(); cell.Distance = 0; frontier.Add(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … neighbor.Distance = distance; frontier.Add(neighbor); } }
Não posso usar o ListPool <HexCell>?, , . , , .
Para que a borda esteja correta, precisamos classificá-la após adicionar uma célula a ela. De fato, podemos adiar a classificação até que todos os vizinhos da célula sejam adicionados, mas, repito, até que as otimizações não nos interessem.Queremos classificar as células por distância. Para fazer isso, precisamos chamar o método de classificação de lista com um link para o método que realiza essa comparação. frontier.Add(neighbor); frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));
Como esse método Sort funciona?. , . .
frontier.Sort(CompareDistances); … static int CompareDistances (HexCell x, HexCell y) { return x.Distance.CompareTo(y.Distance); }
A borda classificada ainda está incorreta.Atualização de fronteira
Depois que começamos a classificar a borda, começamos a obter melhores resultados, mas ainda existem erros. Isso ocorre porque quando uma célula é adicionada à borda, não encontramos necessariamente a menor distância para essa célula. Isso significa que agora não podemos mais ignorar os vizinhos que já receberam uma distância. Em vez disso, precisamos verificar se encontramos um caminho mais curto. Nesse caso, precisamos alterar a distância para o vizinho, em vez de adicioná-lo à borda. HexCell neighbor = current.GetNeighbor(d); if (neighbor == null) { continue; } if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; } frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));
As distâncias corretas.Agora que temos as distâncias corretas, começaremos a considerar os custos de mudança. Você pode perceber que as distâncias para algumas células são inicialmente muito grandes, mas são corrigidas quando removidas da borda. Essa abordagem é chamada algoritmo de Dijkstra, e recebe o nome do primeiro inventado por Edsger Dijkstra.Encostas
Não queremos nos limitar a custos diferentes apenas para estradas. Por exemplo, você pode reduzir o custo de atravessar arestas planas sem estradas para 5, deixando um valor de 10 para pistas sem estradas. HexEdgeType edgeType = current.GetEdgeType(neighbor); if (edgeType == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; }
Para superar as encostas, você precisa trabalhar mais e as estradas são sempre rápidas.Objetos de alívio
Podemos adicionar custos na presença de objetos de alívio. Por exemplo, em muitos jogos, é mais difícil navegar pelas florestas. Nesse caso, simplesmente adicionamos todos os níveis de objetos à distância. E aqui novamente a estrada acelera tudo. if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; }
Objetos diminuem a velocidade se não houver estrada.As paredes
Finalmente, vamos levar em conta as paredes. As paredes devem bloquear o movimento se a estrada não passar por elas. if (current.HasRoadThroughEdge(d)) { distance += 1; } else if (current.Walled != neighbor.Walled) { continue; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; }
As paredes não nos deixam passar, você precisa procurar o portão.unitypackage