Mapas hexagonais no Unity: Path Finder, esquadrões de jogadores, animações

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

Partes 4-7: solavancos, rios e estradas

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

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

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

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

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

Parte 16: encontrando o caminho


  • Realçar células
  • Selecione um destino de pesquisa
  • Encontre o caminho mais curto
  • Crie uma fila prioritária

Depois de calcular as distâncias entre as células, passamos a encontrar os caminhos entre elas.

A partir desta parte, os tutoriais de mapa hexagonal serão criados no Unity 5.6.0. Deve-se notar que na versão 5.6 existe um erro que destrói matrizes de texturas em montagens para várias plataformas. Você pode contornar isso incluindo É legível no inspetor de matriz de textura.


Planejando uma viagem

Células destacadas


Para pesquisar o caminho entre duas células, primeiro precisamos selecionar essas células. É mais do que apenas escolher uma célula e monitorar a pesquisa no mapa. Por exemplo, primeiro selecionaremos a célula inicial e depois a final. Nesse caso, seria conveniente que eles fossem destacados. Portanto, vamos adicionar essa funcionalidade. Até criarmos uma maneira sofisticada ou eficiente de destacar, apenas criamos algo para nos ajudar no desenvolvimento.

Textura de contorno


Uma maneira simples de selecionar células é adicionar um caminho a elas. A maneira mais fácil de fazer isso é com uma textura contendo um contorno hexagonal. Aqui você pode baixar essa textura. É transparente, exceto pelo contorno branco do hexágono. Depois de branco, no futuro poderemos colorir conforme necessário.


Contorno de célula em fundo preto

Importe a textura e defina seu Tipo de textura como Sprite . O Modo Sprite dela será definido como Único com as configurações padrão. Como essa é uma textura excepcionalmente branca, não precisamos converter para sRGB . O canal alfa indica transparência, portanto, ative Alpha é Transparency . Também defino a textura do Modo de filtro como Trilinear , porque, caso contrário, as transições mip para os caminhos podem se tornar muito visíveis.


Opções de importação de textura

Um sprite por célula


A maneira mais rápida é adicionar um possível contorno às células, adicionando cada sprite. Crie um novo objeto de jogo, adicione o componente Imagem ( Componente / UI / Imagem ) e atribua a ele nosso sprite de estrutura de tópicos. Em seguida, insira a instância pré-fabricada do Hex Cell Label na cena, torne o objeto sprite filho dela, aplique as alterações à pré-fabricada e depois se livre da pré-fabricada.



Elemento de seleção filho pré-fabricado

Agora cada célula tem um sprite, mas será muito grande. Para fazer com que os contornos correspondam aos centros das células, altere a Largura e a Altura do componente de transformação do sprite para 17.


Sprites de seleção parcialmente ocultos por um relevo

Desenho em cima de tudo


Como o contorno é sobreposto à área das bordas das células, geralmente aparece sob a geometria do relevo. Por esse motivo, parte do circuito desaparece. Isso pode ser evitado aumentando ligeiramente os sprites na vertical, mas não no caso de quebras. Em vez disso, podemos fazer o seguinte: sempre desenhe sprites em cima de tudo o mais. Para fazer isso, crie seu próprio shader de sprite. Será suficiente copiarmos o shader de sprite padrão do Unity e fazermos algumas alterações.

Shader "Custom/Highlight" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0 [HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1) [HideInInspector] _Flip ("Flip", Vector) = (1,1,1,1) [PerRendererData] _AlphaTex ("External Alpha", 2D) = "white" {} [PerRendererData] _EnableExternalAlpha ("Enable External Alpha", Float) = 0 } SubShader { Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex SpriteVert #pragma fragment SpriteFrag #pragma target 2.0 #pragma multi_compile_instancing #pragma multi_compile _ PIXELSNAP_ON #pragma multi_compile _ ETC1_EXTERNAL_ALPHA #include "UnitySprites.cginc" ENDCG } } } 

A primeira mudança é que ignoramos o buffer de profundidade, tornando o teste Z sempre bem-sucedido.

  ZWrite Off ZTest Always 

A segunda mudança é que estamos renderizando após o restante da geometria transparente. O suficiente para adicionar 10 à fila de transparência.

  "Queue"="Transparent+10" 

Crie um novo material que esse shader usará. Podemos ignorar todas as suas propriedades, aderindo aos valores padrão. Em seguida, faça a pré-fabricada de sprite usar esse material.



Usamos nosso próprio material de sprite

Agora os contornos da seleção estão sempre visíveis. Mesmo se a célula estiver oculta sob um relevo mais alto, seu contorno ainda será desenhado em cima de tudo o resto. Pode não parecer bonito, mas as células selecionadas sempre estarão visíveis, o que é útil para nós.


Ignore o buffer de profundidade

Controle de seleção


Não queremos que todas as células sejam destacadas ao mesmo tempo. De fato, inicialmente todos eles devem ser desmarcados. Podemos implementar isso desabilitando o componente Image do objeto pré-fabricado Highlight .


Componente de imagem desativada

Para habilitar a seleção de células, adicione o método EnableHighlight ao EnableHighlight . Ele deve pegar o único filho do uiRect e incluir o componente Image. Também criaremos o método DisableHighlight .

  public void DisableHighlight () { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.enabled = false; } public void EnableHighlight () { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.enabled = true; } 

Finalmente, podemos especificar a cor para que, quando ativada, dê um tom à luz de fundo.

  public void EnableHighlight (Color color) { Image highlight = uiRect.GetChild(0).GetComponent<Image>(); highlight.color = color; highlight.enabled = true; } 

unitypackage

Encontrando o caminho


Agora que podemos selecionar as células, precisamos seguir em frente e selecionar duas células e, em seguida, encontrar o caminho entre elas. Primeiro, precisamos selecionar as células, depois restringir a pesquisa a um caminho entre elas e, finalmente, mostrar esse caminho.

Início da pesquisa


Precisamos selecionar duas células diferentes, os pontos inicial e final da pesquisa. Suponha que, para selecionar a célula de pesquisa inicial, mantenha pressionada a tecla Shift esquerda enquanto clica no mouse. Nesse caso, a célula é destacada em azul. Precisamos salvar o link para esta célula para futuras pesquisas. Além disso, ao escolher uma nova célula inicial, a seleção da antiga deve ser desativada. Portanto, adicionamos o campo searchFromCell ao searchFromCell .

  HexCell previousCell, searchFromCell; 

Dentro do HandleInput podemos usar Input.GetKey(KeyCode.LeftShift) para testar a tecla Shift pressionada.

  if (editMode) { EditCells(currentCell); } else if (Input.GetKey(KeyCode.LeftShift)) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); } else { hexGrid.FindDistancesTo(currentCell); } 


Onde procurar

Ponto final de pesquisa


Em vez de procurar todas as distâncias de uma célula, agora estamos procurando um caminho entre duas células específicas. Portanto, renomeie HexGrid.FindDistancesTo para HexGrid.FindPath e atribua o segundo parâmetro HexCell , além de alterar o método Search .

  public void FindPath (HexCell fromCell, HexCell toCell) { StopAllCoroutines(); StartCoroutine(Search(fromCell, toCell)); } IEnumerator Search (HexCell fromCell, HexCell toCell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); List<HexCell> frontier = new List<HexCell>(); fromCell.Distance = 0; frontier.Add(fromCell); … } 

Agora HexMapEditor.HandleInput deve chamar o método modificado, usando searchFromCell e currentCell como argumentos. Além disso, só podemos pesquisar quando sabemos em qual célula pesquisar. E não precisamos nos preocupar em procurar se os pontos inicial e final coincidem.

  if (editMode) { EditCells(currentCell); } else if (Input.GetKey(KeyCode.LeftShift)) { … } else if (searchFromCell && searchFromCell != currentCell) { hexGrid.FindPath(searchFromCell, currentCell); } 

Voltando à pesquisa, primeiro precisamos nos livrar de todas as seleções anteriores. Portanto, faça o HexGrid.Search desativar a seleção ao redefinir distâncias. Como isso também desliga a iluminação da célula inicial, ligue-a novamente. Nesta fase, também podemos destacar o ponto final. Vamos fazê-la vermelha.

  IEnumerator Search (HexCell fromCell, HexCell toCell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; cells[i].DisableHighlight(); } fromCell.EnableHighlight(Color.blue); toCell.EnableHighlight(Color.red); … } 


Pontos finais de um caminho potencial

Limitar pesquisa


Nesse ponto, nosso algoritmo de pesquisa ainda calcula as distâncias para todas as células acessíveis a partir da célula inicial. Mas não precisamos mais disso. Podemos parar assim que encontrarmos a distância final para a célula final. Ou seja, quando a célula atual é finita, podemos sair do loop do algoritmo.

  while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); if (current == toCell) { break; } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } } 


Pare no ponto final

O que acontece se o ponto final não puder ser alcançado?
Em seguida, o algoritmo continuará funcionando até encontrar todas as células acessíveis. Sem a possibilidade de uma saída prematura, ele funcionará como o antigo método FindDistancesTo .

Exibição de caminho


Podemos encontrar a distância entre o começo e o fim do caminho, mas ainda não sabemos qual será o caminho real. Para encontrá-lo, você precisa acompanhar como cada célula é alcançada. Mas como fazer isso?

Ao adicionar uma célula à borda, fazemos isso porque é uma vizinha da célula atual. A única exceção é a célula inicial. Todas as outras células foram alcançadas através da célula atual. Se mantivermos o controle de qual célula cada célula foi alcançada, obtemos uma rede de células como resultado. Mais precisamente, uma rede em forma de árvore, cuja raiz é o ponto de partida. Podemos usá-lo para construir o caminho depois de atingir o ponto final.


Rede em árvore que descreve os caminhos para o centro

Podemos salvar essas informações adicionando um link para outra célula no HexCell . Como não precisamos serializar esses dados, usamos a propriedade padrão para isso.

  public HexCell PathFrom { get; set; } 

No HexGrid.Search defina o valor PathFrom do vizinho para a célula atual ao adicioná-lo à borda. Além disso, precisamos alterar esse link quando encontrarmos um caminho mais curto para o vizinho.

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; neighbor.PathFrom = current; } 

Tendo atingido o ponto final, podemos visualizar o caminho seguindo esses links de volta à célula inicial e selecioná-los.

  if (current == toCell) { current = current.PathFrom; while (current != fromCell) { current.EnableHighlight(Color.white); current = current.PathFrom; } break; } 


Caminho encontrado

Vale a pena considerar que muitas vezes existem vários caminhos mais curtos. O encontrado depende da ordem de processamento das células. Alguns caminhos podem parecer bons, outros podem ser ruins, mas nunca há um caminho mais curto. Voltaremos a isso mais tarde.

Alterar o início da pesquisa


Após selecionar o ponto inicial, a alteração do ponto final acionará uma nova pesquisa. O mesmo deve acontecer ao escolher uma nova célula inicial. Para tornar isso possível, o HexMapEditor também deve se lembrar do ponto final.

  HexCell previousCell, searchFromCell, searchToCell; 

Usando esse campo, também podemos iniciar uma nova pesquisa ao escolher um novo começo.

  else if (Input.GetKey(KeyCode.LeftShift)) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell); } } else if (searchFromCell && searchFromCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell); } 

Além disso, precisamos evitar pontos iguais de início e de término.

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { … } 

unitypackage

Pesquisa mais inteligente


Embora nosso algoritmo encontre o caminho mais curto, ele passa muito tempo explorando pontos que obviamente não farão parte desse caminho. Pelo menos é óbvio para nós. O algoritmo não pode olhar para baixo no mapa; não pode ver que uma pesquisa em algumas direções não faz sentido. Ele prefere seguir as estradas, apesar de estarem na direção oposta ao ponto final. É possível tornar a pesquisa mais inteligente?

No momento, ao escolher a célula a ser processada a seguir, consideramos apenas a distância da célula ao início. Se queremos ser mais inteligentes, também devemos considerar a distância até o ponto final. Infelizmente, ainda não o conhecemos. Mas podemos criar uma estimativa da distância restante. Adicionar esta estimativa à distância da célula nos dá uma compreensão do comprimento total do caminho que passa por essa célula. Em seguida, podemos usá-lo para priorizar pesquisas em células.

Heurística da Pesquisa


Quando usamos estimativas ou suposições em vez de dados exatamente conhecidos, isso é chamado usando heurísticas de pesquisa. Essa heurística representa o melhor palpite da distância restante. Devemos determinar esse valor para cada célula que estamos procurando, portanto, adicionaremos uma propriedade inteira HexCell . Não precisamos serializá-lo, portanto outra propriedade padrão será suficiente.

  public int SearchHeuristic { get; set; } 

Como fazemos uma suposição sobre a distância restante? No caso mais ideal, teremos uma estrada que leva diretamente ao ponto final. Nesse caso, a distância é igual à distância inalterada entre as coordenadas dessa célula e a célula final. Vamos tirar proveito disso em nossa heurística.

Como a heurística não depende de um caminho percorrido anteriormente, é constante no processo de busca. Portanto, precisamos calculá-lo apenas uma vez quando o HexGrid.Search adicionar uma célula à borda.

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); frontier.Add(neighbor); } 

Prioridade de pesquisa


A partir de agora, determinaremos a prioridade da pesquisa com base na distância da célula mais suas heurísticas. Vamos adicionar uma propriedade para esse valor no HexCell .

  public int SearchPriority { get { return distance + SearchHeuristic; } } 

Para que isso funcione, HexGrid.Search para que ele use essa propriedade para classificar a borda.

  frontier.Sort( (x, y) => x.SearchPriority.CompareTo(y.SearchPriority) ); 



Pesquisa sem heurística e com heurística

Heurística válida


Graças às novas prioridades de pesquisa, visitaremos menos células como resultado. No entanto, em um mapa uniforme, o algoritmo ainda processa células que estão na direção errada. Isso ocorre porque, por padrão, os custos para cada etapa da movimentação são 5 e a heurística por etapa adiciona apenas 1. Ou seja, a influência da heurística não é muito forte.

Se os custos de movimentação de todas as cartas forem os mesmos, podemos usar os mesmos custos ao determinar a heurística. No nosso caso, essa será a heurística atual multiplicada por 5. Isso reduzirá significativamente o número de células processadas.


Usando heurística × 5

No entanto, se houver estradas no mapa, podemos superestimar a distância restante. Como resultado, o algoritmo pode cometer erros e criar um caminho que na verdade não é o mais curto.



Heurísticas superestimadas e válidas

Para garantir que o caminho mais curto seja encontrado, precisamos garantir que nunca superestimamos a distância restante. Essa abordagem é chamada heurística válida. Como o custo mínimo de movimentação é 1, não temos escolha a não ser usar os mesmos custos na determinação da heurística.

A rigor, é bastante normal usar custos ainda mais baixos, mas isso só tornará a heurística mais fraca. A heurística mínima possível é zero, o que nos dá apenas o algoritmo de Dijkstra. Para heurísticas diferentes de zero, o algoritmo é chamado A * (pronunciado "Uma estrela").

Por que é chamado A *?
A idéia de adicionar heurísticas ao algoritmo de Dijkstra foi proposta pela primeira vez por Niels Nilsson. Ele nomeou sua versão A1. Mais tarde, Bertram Rafael apresentou a melhor versão que ele chamou de A2. Então, Peter Hart provou que, com uma boa heurística A2, é ideal, ou seja, não pode haver uma versão melhor. Isso o forçou a chamar o algoritmo A * para mostrar que não poderia ser melhorado, ou seja, A3 ou A4 não apareceria. Então, sim, o algoritmo A * é o melhor que podemos obter, mas é tão bom quanto sua heurística.

unitypackage

Fila prioritária


Embora A * seja um bom algoritmo, nossa implementação não é tão eficaz, porque para armazenar a borda, usamos uma lista que precisa ser classificada em cada iteração. Como mencionado na parte anterior, precisamos de uma fila de prioridade, mas sua implementação padrão não existe. Portanto, vamos criar você mesmo.

Nossa vez deve apoiar a operação de configuração e exclusão da fila com base na prioridade. Ele também deve suportar a alteração da prioridade de uma célula que já está na fila. Idealmente, nós o implementamos, minimizando a busca por classificação e memória alocada. Além disso, deve permanecer simples.

Crie sua própria fila


Crie uma nova classe HexCellPriorityQueue com os métodos comuns necessários. Usamos uma lista simples para rastrear o conteúdo de uma fila. Além disso, adicionaremos o método Clear para limpar a fila para que possa ser usado repetidamente.

 using System.Collections.Generic; public class HexCellPriorityQueue { List<HexCell> list = new List<HexCell>(); public void Enqueue (HexCell cell) { } public HexCell Dequeue () { return null; } public void Change (HexCell cell) { } public void Clear () { list.Clear(); } } 

Armazenamos as prioridades das células nas próprias células. Ou seja, antes de adicionar uma célula à fila, sua prioridade deve ser definida. Mas, no caso de uma mudança de prioridade, provavelmente será útil saber qual era a antiga prioridade. Então, vamos adicionar isso a Change como um parâmetro.

  public void Change (HexCell cell, int oldPriority) { } 

Também é útil saber quantas células estão na fila, então vamos adicionar a propriedade Count para isso. Basta usar o campo para o qual executaremos o incremento e decremento correspondente.

  int count = 0; public int Count { get { return count; } } public void Enqueue (HexCell cell) { count += 1; } public HexCell Dequeue () { count -= 1; return null; } … public void Clear () { list.Clear(); count = 0; } 

Adicionar à fila


Quando uma célula é adicionada à fila, primeiro vamos usar sua prioridade como um índice, tratando a lista como uma matriz simples.

  public void Enqueue (HexCell cell) { count += 1; int priority = cell.SearchPriority; list[priority] = cell; } 

No entanto, isso só funciona se a lista for longa o suficiente, caso contrário, iremos além das fronteiras. Você pode evitar isso adicionando itens vazios à lista até atingir o comprimento necessário. Esses elementos vazios não fazem referência à célula, para que você possa criá-los adicionando null à lista.

  int priority = cell.SearchPriority; while (priority >= list.Count) { list.Add(null); } list[priority] = cell; 


Lista com furos

Mas é assim que armazenamos apenas uma célula por prioridade e, provavelmente, haverá várias. Para rastrear todas as células com a mesma prioridade, precisamos usar outra lista. Embora possamos usar uma lista real para todas as prioridades, também podemos adicionar uma propriedade ao HexCell para HexCell -las. Isso nos permite criar uma cadeia de células chamada lista vinculada.

  public HexCell NextWithSamePriority { get; set; } 

Para criar uma cadeia, deixe HexCellPriorityQueue.Enqueue forçar a célula recém-adicionada a se referir ao valor atual com a mesma prioridade, antes de excluí-lo.

  cell.NextWithSamePriority = list[priority]; list[priority] = cell; 


Lista de listas vinculadas

Remover da fila


Para obter uma célula de uma fila prioritária, precisamos acessar a lista vinculada no menor índice não vazio. Portanto, percorreremos a lista em um loop até encontrá-la. Se não encontrarmos, a fila está vazia e retornamos null .

Da cadeia encontrada, podemos retornar qualquer célula, porque todas elas têm a mesma prioridade. A maneira mais fácil é devolver a célula desde o início da cadeia.

  public HexCell Dequeue () { count -= 1; for (int i = 0; i < list.Count; i++) { HexCell cell = list[i]; if (cell != null) { return cell; } } return null; } 

Para manter o link para a cadeia restante, use a próxima célula com a mesma prioridade que o novo início. Se houvesse apenas uma célula nesse nível de prioridade, o elemento se null e será ignorado no futuro.

  if (cell != null) { list[i] = cell.NextWithSamePriority; return cell; } 

Rastreamento mínimo


Essa abordagem funciona, mas percorre a lista sempre que uma célula é recebida. Não podemos evitar encontrar o menor índice não vazio, mas não somos obrigados a começar do zero todas as vezes. Em vez disso, podemos rastrear a prioridade mínima e iniciar a pesquisa com ela. Inicialmente, o mínimo é essencialmente igual ao infinito.

  int minimum = int.MaxValue; … public void Clear () { list.Clear(); count = 0; minimum = int.MaxValue; } 

Ao adicionar uma célula à fila, alteramos o mínimo conforme necessário.

  public void Enqueue (HexCell cell) { count += 1; int priority = cell.SearchPriority; if (priority < minimum) { minimum = priority; } … } 

E ao sair da fila, usamos pelo menos a lista para iterações e não começamos do zero.

  public HexCell Dequeue () { count -= 1; for (; minimum < list.Count; minimum++) { HexCell cell = list[minimum]; if (cell != null) { list[minimum] = cell.NextWithSamePriority; return cell; } } return null; } 

Isso reduz significativamente a quantidade de tempo necessária para ignorar o loop da lista de prioridades.

Alterar prioridades


Ao alterar a prioridade de uma célula, ela deve ser removida da lista vinculada da qual faz parte. Para fazer isso, precisamos seguir a cadeia até encontrá-la.

Vamos começar declarando que o cabeçalho da lista de prioridades antiga será a célula atual e também rastrearemos a próxima célula. Podemos pegar a próxima célula imediatamente, porque sabemos que há pelo menos uma célula nesse índice.

  public void Change (HexCell cell, int oldPriority) { HexCell current = list[oldPriority]; HexCell next = current.NextWithSamePriority; } 

Se a célula atual é uma célula alterada, essa é a célula principal e podemos cortá-la como se a tivéssemos retirado da fila.

  HexCell current = list[oldPriority]; HexCell next = current.NextWithSamePriority; if (current == cell) { list[oldPriority] = next; } 

Se não for esse o caso, precisamos seguir a cadeia até estarmos na célula em frente à célula alterada. Ele contém um link para a célula que foi modificada.

  if (current == cell) { list[oldPriority] = next; } else { while (next != cell) { current = next; next = current.NextWithSamePriority; } } 

Nesse ponto, podemos remover a célula alterada da lista vinculada, ignorando-a.

  while (next != cell) { current = next; next = current.NextWithSamePriority; } current.NextWithSamePriority = cell.NextWithSamePriority; 

Após excluir uma célula, é necessário adicioná-la novamente para que ela apareça na lista de sua nova prioridade.

  public void Change (HexCell cell, int oldPriority) { … Enqueue(cell); } 

O método Enqueue incrementa o contador, mas, na realidade, não estamos adicionando uma nova célula. Portanto, para compensar isso, teremos que diminuir o contador.

  Enqueue(cell); count -= 1; 

Uso da fila


Agora podemos tirar proveito da nossa fila de prioridades no HexGrid . Isso pode ser feito com uma única instância, reutilizável para todas as operações de pesquisa.

  HexCellPriorityQueue searchFrontier; … IEnumerator Search (HexCell fromCell, HexCell toCell) { if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } … } 

Antes de iniciar o loop, o método Searchdeve primeiro ser adicionado à fila fromCelle cada iteração começa com a saída da célula da fila. Isso substituirá o código de borda antigo.

  WaitForSeconds delay = new WaitForSeconds(1 / 60f); // List<HexCell> frontier = new List<HexCell>(); fromCell.Distance = 0; // frontier.Add(fromCell); searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { yield return delay; HexCell current = searchFrontier.Dequeue(); // frontier.RemoveAt(0); … } 

Altere o código para adicionar e alterar o vizinho. Antes da mudança, lembraremos da antiga prioridade.

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); // frontier.Add(neighbor); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 

Além disso, não precisamos mais classificar a borda.

 // frontier.Sort( // (x, y) => x.SearchPriority.CompareTo(y.SearchPriority) // ); 


Pesquisa usando uma fila de prioridade

Como mencionado anteriormente, o caminho mais curto encontrado depende da ordem de processamento das células. Nossa vez cria uma ordem diferente da ordem da lista classificada, para que possamos obter outras maneiras. Como adicionamos e removemos do cabeçalho da lista vinculada para cada prioridade, elas são mais como pilhas do que filas. As células adicionadas por último são processadas primeiro. Um efeito colateral dessa abordagem é que o algoritmo é propenso a ziguezague. Portanto, a probabilidade de caminhos em zigue-zague também aumenta. Felizmente, esses caminhos geralmente parecem melhores, então esse efeito colateral é a nosso favor.



Lista e fila classificadas com prioridade para

unitypackage

Parte 17: movimento limitado


  • Encontramos formas de movimento passo a passo.
  • Exiba imediatamente o caminho.
  • Criamos uma pesquisa mais eficaz.
  • Nós visualizamos apenas o caminho.

Nesta parte, dividiremos o movimento em movimentos e aceleraremos a busca o máximo possível.


Viaje de vários movimentos

Movimento passo a passo


Jogos de estratégia que usam redes hexagonais são quase sempre baseados em turnos. As unidades que se deslocam no mapa têm uma velocidade limitada, o que limita a distância percorrida em um turno.

Velocidade


Para fornecer suporte a movimentos limitados, adicionamos HexGrid.FindPathe HexGrid.Searchinserimos o parâmetro inteiro speed. Determina a amplitude de movimento de um movimento.

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { StopAllCoroutines(); StartCoroutine(Search(fromCell, toCell, speed)); } IEnumerator Search (HexCell fromCell, HexCell toCell, int speed) { … } 

Diferentes tipos de unidades no jogo usam velocidades diferentes. A cavalaria é rápida, a infantaria é lenta e assim por diante. Ainda não temos unidades, portanto, por enquanto, usaremos uma velocidade constante. Vamos pegar um valor de 24. Esse é um valor razoavelmente grande, não divisível por 5 (o custo padrão da mudança). Adicionar um argumento para FindPatha HexMapEditor.HandleInputuma velocidade constante.

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell, 24); } } else if (searchFromCell && searchFromCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell, 24); } 

Movimentos


Além de rastrear o custo total da movimentação ao longo do caminho, agora também precisamos saber quantos movimentos serão necessários para movê-lo. Mas não precisamos armazenar essas informações em cada célula. Pode ser obtido dividindo a distância percorrida pela velocidade. Como esses números são inteiros, usaremos a divisão inteira. Ou seja, as distâncias totais não superiores a 24 correspondem ao curso 0. Isso significa que todo o caminho pode ser concluído no curso atual. Se o ponto final estiver a uma distância de 30, esse deverá ser o turno 1. Para chegar ao ponto final, a unidade terá que gastar todo o seu movimento no turno atual e em parte do próximo turno.

Vamos determinar o curso da célula atual e todos os seus vizinhos dentroHexGrid.Search. O curso da célula atual pode ser calculado apenas uma vez, logo antes de dar a volta no ciclo vizinho. A jogada do vizinho pode ser determinada assim que encontrarmos a distância para ele.

  int currentTurn = current.Distance / speed; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int distance = current.Distance; 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; } int turn = distance / speed; … } 

Movimento perdido


Se o movimento do vizinho for maior que o movimento atual, cruzamos o limite do movimento. Se o movimento necessário para alcançar um vizinho foi 1, tudo está bem. Mas se a mudança para a próxima célula for mais cara, tudo ficará mais complicado.

Suponha que movamos ao longo de um mapa homogêneo, ou seja, para entrar em cada célula, você precisa de 5 unidades de movimento. Nossa velocidade é 24. Após quatro etapas, gastamos 20 unidades de nosso estoque de movimento e restam 4. Na próxima etapa, são necessárias 5 unidades novamente, ou seja, uma a mais do que as disponíveis. O que precisamos fazer nesta fase?

Existem duas abordagens para essa situação. A primeira é permitir que a unidade entre na quinta célula no turno atual, mesmo que não tenhamos movimento suficiente. O segundo é proibir o movimento durante o movimento atual, ou seja, os pontos de movimento restantes não podem ser usados ​​e eles serão perdidos.

A escolha da opção depende do jogo. Em geral, a primeira abordagem é mais apropriada para jogos nos quais as unidades podem se mover apenas alguns passos por turno, por exemplo, para jogos da série Civilization. Isso garante que as unidades sempre possam mover pelo menos uma célula por turno. Se as unidades puderem mover muitas células por turno, como no Age of Wonders ou no Battle for Wesnoth, a segunda opção será melhor.

Como usamos a velocidade 24, vamos escolher a segunda abordagem. Para que ele comece a funcionar, precisamos isolar os custos de entrar na próxima célula antes de adicioná-la à distância atual.

 // int distance = current.Distance; int moveCost; if (current.HasRoadThroughEdge(d)) { moveCost = 1; } else if (current.Walled != neighbor.Walled) { continue; } else { moveCost = edgeType == HexEdgeType.Flat ? 5 : 10; moveCost += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; } int distance = current.Distance + moveCost; int turn = distance / speed; 

Se, como resultado, cruzarmos a borda do movimento, primeiro usaremos todos os pontos de movimento do movimento atual. Podemos fazer isso simplesmente multiplicando o movimento pela velocidade. Depois disso, adicionamos o custo da mudança.

  int distance = current.Distance + moveCost; int turn = distance / speed; if (turn > currentTurn) { distance = turn * speed + moveCost; } 

Como resultado, concluiremos o primeiro movimento na quarta célula com 4 pontos de movimento não utilizados. Esses pontos perdidos são adicionados aos custos da quinta célula, de modo que sua distância se torna 29, e não 25. Como resultado, as distâncias são maiores do que antes. Por exemplo, a décima célula tinha uma distância de 50. Mas agora, para entrar nela, precisamos cruzar as fronteiras de dois movimentos, perdendo 8 pontos de movimento, ou seja, a distância até agora se torna 58.


Mais do que o esperado

Como os pontos de movimento não utilizados são adicionados às distâncias das células, eles são levados em consideração na determinação do caminho mais curto. A maneira mais eficaz é desperdiçar o mínimo de pontos possível. Portanto, em velocidades diferentes, podemos obter caminhos diferentes.

Mostrando movimentos em vez de distâncias


Quando jogamos o jogo, não estamos muito interessados ​​nos valores de distância usados ​​para encontrar o caminho mais curto. Estamos interessados ​​no número de movimentos necessários para alcançar o ponto final. Portanto, em vez de distâncias, vamos exibir os movimentos.

Primeiro, livre-se da UpdateDistanceLabelligação dele HexCell.

  public int Distance { get { return distance; } set { distance = value; // UpdateDistanceLabel(); } } … // void UpdateDistanceLabel () { // UnityEngine.UI.Text label = uiRect.GetComponent<Text>(); // label.text = distance == int.MaxValue ? "" : distance.ToString(); // } 

Em vez disso, adicionaremos ao HexCellmétodo geral SetLabelque recebe uma string arbitrária.

  public void SetLabel (string text) { UnityEngine.UI.Text label = uiRect.GetComponent<Text>(); label.text = text; } 

Usamos esse novo método na HexGrid.Searchlimpeza de células. Para ocultar células, basta atribuí-las null.

  for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; cells[i].SetLabel(null); cells[i].DisableHighlight(); } 

Em seguida, atribuímos à marca do vizinho o valor de sua jogada. Depois disso, poderemos ver quantos movimentos adicionais serão necessários para percorrer todo o caminho.

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 


O número de movimentos necessários para percorrer o caminho do

unitypackage

Caminhos instantâneos


Além disso, quando jogamos o jogo, não nos importamos como o algoritmo de pesquisa de caminho encontra o caminho. Queremos ver o caminho solicitado imediatamente. No momento, podemos ter certeza de que o algoritmo funciona, então vamos nos livrar da visualização de pesquisa.

Sem corutin


Para uma passagem lenta pelo algoritmo, usamos corutin. Mais disto que precisamos, então se livrar da chamada StartCoroutinee StopAllCoroutinesno HexGrid. Em vez disso, simplesmente o invocamos Searchcomo um método regular.

  public void Load (BinaryReader reader, int header) { // StopAllCoroutines(); … } public void FindPath (HexCell fromCell, HexCell toCell, int speed) { // StopAllCoroutines(); // StartCoroutine(Search(fromCell, toCell, speed)); Search(fromCell, toCell, speed); } 

Como não a usamos mais Searchcomo corotina, ela não precisa de rendimento, portanto, nos livraremos desse operador. Isso significa que também removeremos a declaração WaitForSecondse alteraremos o tipo de retorno do método para void.

  void Search (HexCell fromCell, HexCell toCell, int speed) { … // WaitForSeconds delay = new WaitForSeconds(1 / 60f); fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { // yield return delay; HexCell current = searchFrontier.Dequeue(); … } } 


Resultados instantâneos

Definição do tempo de pesquisa


Agora podemos encontrar os caminhos instantaneamente, mas com que rapidez eles são calculados? Caminhos curtos aparecem quase imediatamente, mas caminhos longos em mapas grandes podem parecer um pouco lentos.

Vamos medir quanto tempo leva para encontrar e exibir o caminho. Podemos usar um criador de perfil para determinar o tempo de pesquisa, mas isso é um pouco demais e cria custos adicionais. Vamos usar em vez disso Stopwatch, que está no espaço para nome System.Diagnostics. Como o usamos apenas temporariamente, não adicionarei a construção usingao início do script.

Logo antes da pesquisa, crie um novo cronômetro e inicie-o. Após a conclusão da pesquisa, pare o cronômetro e exiba o tempo decorrido no console.

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); sw.Start(); Search(fromCell, toCell, speed); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds); } 

Vamos escolher o pior caso para o nosso algoritmo - uma pesquisa do canto inferior esquerdo ao canto superior direito de um mapa grande. O pior é um mapa uniforme, porque o algoritmo terá que processar todas as 4.800 células do mapa.


Pesquise no pior dos casos O tempo

gasto na pesquisa pode ser diferente, porque o editor do Unity não é o único processo em execução na sua máquina. Portanto, teste-o várias vezes para entender a duração média. No meu caso, a pesquisa leva cerca de 45 milissegundos. Isso não é muito e corresponde a 22,22 caminhos por segundo; denote isso como 22 pps (caminhos por segundo). Isso significa que a taxa de quadros do jogo também diminuirá em um máximo de 22 qps nesse quadro quando esse caminho for calculado. E isso sem levar em consideração todos os outros trabalhos, por exemplo, renderizar o próprio quadro. Ou seja, temos uma diminuição bastante grande na taxa de quadros, ela cairá para 20 fps.

Ao executar esse teste de desempenho, é necessário considerar que o desempenho do editor do Unity não será tão alto quanto o desempenho do aplicativo finalizado. Se eu realizar o mesmo teste com a montagem, em média, serão necessários apenas 15 ms. São 66 pps, o que é muito melhor. No entanto, essa ainda é uma grande parte dos recursos alocados por quadro, portanto a taxa de quadros ficará menor que 60 fps.

Onde posso ver o log de depuração do assembly?
Unity , . . , , Unity Log Files .

Pesquise apenas se necessário.


Podemos fazer uma otimização simples - realizar uma pesquisa somente quando necessário. Enquanto iniciamos uma nova pesquisa em cada quadro em que o botão do mouse é pressionado. Portanto, a taxa de quadros será constantemente subestimada ao arrastar e soltar. Podemos evitar isso iniciando uma nova pesquisa HexMapEditor.HandleInputapenas quando estamos realmente lidando com um novo ponto de extremidade. Caso contrário, o caminho visível atual ainda é válido.

  if (editMode) { EditCells(currentCell); } else if ( Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell ) { if (searchFromCell != currentCell) { if (searchFromCell) { searchFromCell.DisableHighlight(); } searchFromCell = currentCell; searchFromCell.EnableHighlight(Color.blue); if (searchToCell) { hexGrid.FindPath(searchFromCell, searchToCell, 24); } } } else if (searchFromCell && searchFromCell != currentCell) { if (searchToCell != currentCell) { searchToCell = currentCell; hexGrid.FindPath(searchFromCell, searchToCell, 24); } } 

Mostrar rótulos apenas para o caminho


Exibir marcas de viagem é uma operação bastante cara, principalmente porque usamos uma abordagem não otimizada. Executar esta operação para todas as células definitivamente atrasará a execução. Então, vamos pular a marcação HexGrid.Search.

  if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; // neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } else if (distance < neighbor.Distance) { int oldPriority = neighbor.SearchPriority; neighbor.Distance = distance; // neighbor.SetLabel(turn.ToString()); neighbor.PathFrom = current; searchFrontier.Change(neighbor, oldPriority); } 

Precisamos ver essas informações apenas para o caminho encontrado. Portanto, depois de atingir o ponto final, calcularemos o curso e definiremos os rótulos apenas das células que estão a caminho.

  if (current == toCell) { current = current.PathFrom; while (current != fromCell) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } break; } 


Exibindo rótulos apenas para células de caminho

Agora, incluímos apenas rótulos de células entre o início e o fim. Mas o ponto final é a coisa mais importante, também devemos definir um rótulo para ele. Você pode fazer isso iniciando o ciclo do caminho a partir da célula de destino e não da célula na frente dela. Nesse caso, a iluminação do ponto final do vermelho mudará para branco, então removeremos a luz de fundo do ciclo.

  fromCell.EnableHighlight(Color.blue); // toCell.EnableHighlight(Color.red); fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); if (current == toCell) { // current = current.PathFrom; while (current != fromCell) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } toCell.EnableHighlight(Color.red); break; } … } 


As informações de progresso são mais importantes para o terminal.Depois

dessas alterações, o pior caso é reduzido para 23 milissegundos no editor e para 6 milissegundos na montagem finalizada. Estes são 43 pps e 166 pps - muito melhor.

unitypackage

A pesquisa mais inteligente


Na parte anterior, tornamos o procedimento de pesquisa mais inteligente, implementando o algoritmo A * . No entanto, na realidade, ainda não estamos realizando a pesquisa da maneira mais ideal. Em cada iteração, calculamos as distâncias da célula atual para todos os seus vizinhos. Isso é verdade para células que ainda não fazem parte da borda de pesquisa. Mas as células que já foram removidas da borda não precisam mais ser consideradas, porque já encontramos o caminho mais curto para essas células. A implementação correta de A * ignora essas células, para que possamos fazer o mesmo.

Fase de Pesquisa Celular


Como sabemos se uma célula já saiu da fronteira? Enquanto não podemos determinar isso. Portanto, você precisa acompanhar em que fase da pesquisa a célula está. Ela ainda não estava na fronteira, ou está agora ou está no exterior. Podemos acompanhar isso adicionando a uma HexCellpropriedade inteira simples.

  public int SearchPhase { get; set; } 

Por exemplo, 0 significa que as células ainda não atingiram, 1 - que a célula está na borda agora e 2 - que já foi removida da borda.

Atingindo a fronteira


Em HexGrid.Searchpodemos redefinir todas as células para 0 e sempre usar 1 para a borda. Ou podemos aumentar o número de bordas a cada nova pesquisa. Graças a isso, não teremos que lidar com o despejo de células se aumentarmos o número de fronteiras a cada duas vezes.

  int searchFrontierPhase; … void Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; … } 

Agora precisamos definir a fase da pesquisa de células ao adicioná-las à borda. O processo começa com uma célula inicial, que é adicionada à borda.

  fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); 

E também toda vez que adicionamos um vizinho à fronteira.

  if (neighbor.Distance == int.MaxValue) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } 

Verificação de fronteira


Até agora, para verificar se a célula ainda não foi adicionada à borda, usamos uma distância igual a int.MaxValue. Agora podemos comparar a fase da pesquisa de células com a borda atual.

 // if (neighbor.Distance == int.MaxValue) { if (neighbor.SearchPhase < searchFrontierPhase) { neighbor.SearchPhase = searchFrontierPhase; neighbor.Distance = distance; neighbor.PathFrom = current; neighbor.SearchHeuristic = neighbor.coordinates.DistanceTo(toCell.coordinates); searchFrontier.Enqueue(neighbor); } 

Isso significa que não precisamos mais redefinir as distâncias das células antes de pesquisar, ou seja, teremos que fazer menos trabalho, o que é bom.

  for (int i = 0; i < cells.Length; i++) { // cells[i].Distance = int.MaxValue; cells[i].SetLabel(null); cells[i].DisableHighlight(); } 

Saindo da fronteira


Quando uma célula é removida do limite, denotamos isso por um aumento em sua fase de pesquisa. Isso a coloca além da fronteira atual e antes da próxima.

  while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; … } 

Agora podemos pular células removidas da borda, evitando cálculos inúteis e comparações de distâncias.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } … } 

Neste ponto, nosso algoritmo ainda produz os mesmos resultados, mas com mais eficiência. Na minha máquina, a pesquisa de pior caso leva 20 ms no editor e 5 ms na montagem.

Também podemos calcular quantas vezes a célula foi processada pelo algoritmo, aumentando o contador ao calcular a distância até a célula. Anteriormente, nosso algoritmo, na pior das hipóteses, calculava 28.239 distâncias. No algoritmo A * pronto, calculamos suas 14.120 distâncias. A quantidade diminuiu 50%. O grau de impacto desses indicadores na produtividade depende dos custos no cálculo do custo da mudança. No nosso caso, não há muito trabalho aqui, portanto, a melhoria na montagem não é muito grande, mas é muito perceptível no editor.

unitypackage

Abrindo caminho


Ao iniciar uma nova pesquisa, primeiro precisamos limpar a visualização do caminho anterior. Enquanto fazemos isso, desative a seleção e remova os rótulos de cada célula da grade. Esta é uma abordagem muito difícil. Idealmente, precisamos descartar apenas as células que faziam parte do caminho anterior.

Somente Pesquisa


Vamos começar removendo completamente o código de visualização de Search. Ele só precisa realizar uma pesquisa de caminho e não precisa saber o que faremos com essas informações.

  void Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } // for (int i = 0; i < cells.Length; i++) { // cells[i].SetLabel(null); // cells[i].DisableHighlight(); // } // fromCell.EnableHighlight(Color.blue); fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; if (current == toCell) { // while (current != fromCell) { // int turn = current.Distance / speed; // current.SetLabel(turn.ToString()); // current.EnableHighlight(Color.white); // current = current.PathFrom; // } // toCell.EnableHighlight(Color.red); // break; } … } } 

Para relatar que Searchencontramos uma maneira, retornaremos booleano.

  bool Search (HexCell fromCell, HexCell toCell, int speed) { searchFrontierPhase += 2; if (searchFrontier == null) { searchFrontier = new HexCellPriorityQueue(); } else { searchFrontier.Clear(); } fromCell.SearchPhase = searchFrontierPhase; fromCell.Distance = 0; searchFrontier.Enqueue(fromCell); while (searchFrontier.Count > 0) { HexCell current = searchFrontier.Dequeue(); current.SearchPhase += 1; if (current == toCell) { return true; } … } return false; } 

Lembre-se do caminho


Quando o caminho é encontrado, precisamos nos lembrar dele. Graças a isso, poderemos limpá-lo no futuro. Portanto, rastrearemos os pontos finais e se existe um caminho entre eles.

  HexCell currentPathFrom, currentPathTo; bool currentPathExists; … public void FindPath (HexCell fromCell, HexCell toCell, int speed) { System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); sw.Start(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds); } 

Exibir o caminho novamente


Podemos usar os dados de pesquisa que gravamos para visualizar o caminho novamente. Vamos criar um novo método para isso ShowPath. Ele percorrerá o ciclo do final ao início do caminho, destacando as células e atribuindo um valor de traçado aos seus rótulos. Para fazer isso, precisamos saber a velocidade, portanto, torne-o um parâmetro. Se não temos um caminho, o método simplesmente seleciona os pontos finais.

  void ShowPath (int speed) { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { int turn = current.Distance / speed; current.SetLabel(turn.ToString()); current.EnableHighlight(Color.white); current = current.PathFrom; } } currentPathFrom.EnableHighlight(Color.blue); currentPathTo.EnableHighlight(Color.red); } 

Chame esse método FindPathapós a pesquisa.

  currentPathExists = Search(fromCell, toCell, speed); ShowPath(speed); 

Varrer


Nós vemos o caminho novamente, mas agora ele não está se afastando. Para limpá-lo, crie um método ClearPath. De fato, é uma cópia ShowPath, exceto que desativa a seleção e os rótulos, mas não os inclui. Feito isso, ele deve limpar os dados do caminho gravado que não são mais válidos.

  void ClearPath () { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { current.SetLabel(null); current.DisableHighlight(); current = current.PathFrom; } current.DisableHighlight(); currentPathExists = false; } currentPathFrom = currentPathTo = null; } 

Usando esse método, podemos limpar a visualização do caminho antigo, visitando apenas as células necessárias; o tamanho do mapa não é mais importante. Vamos chamá-lo FindPathantes de iniciar uma nova pesquisa.

  sw.Start(); ClearPath(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); if (currentPathExists) { ShowPath(speed); } sw.Stop(); 

Além disso, limparemos o caminho ao criar um novo mapa.

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

E também antes de carregar outro cartão.

  public void Load (BinaryReader reader, int header) { ClearPath(); … } 

A visualização do caminho é limpa novamente, como antes dessa alteração. Mas agora estamos usando uma abordagem mais eficiente e, no pior caso da pesquisa, o tempo diminuiu para 14 milissegundos. Bastante melhoria séria apenas devido à limpeza mais inteligente. O tempo de montagem diminuiu para 3 ms, ou seja, 333 pps. Graças a isso, a pesquisa de caminhos é exatamente aplicável em tempo real.

Agora que fizemos uma pesquisa rápida por caminhos, podemos remover o código de depuração temporário.

  public void FindPath (HexCell fromCell, HexCell toCell, int speed) { // System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch(); // sw.Start(); ClearPath(); currentPathFrom = fromCell; currentPathTo = toCell; currentPathExists = Search(fromCell, toCell, speed); ShowPath(speed); // sw.Stop(); // Debug.Log(sw.ElapsedMilliseconds); } 

unitypackage

Parte 18: unidades


  • Colocamos os esquadrões no mapa.
  • Salve e carregue esquadrões.
  • Nós encontramos caminhos para as tropas.
  • Nós movemos as unidades.

Agora que descobrimos como procurar um caminho, vamos colocar os esquadrões no mapa.


Chegaram reforços

Criando esquadrões


Até agora, lidamos apenas com células e seus objetos fixos. As unidades diferem deles por serem móveis. Um esquadrão pode significar qualquer coisa de qualquer escala, de uma pessoa ou veículo a um exército inteiro. Neste tutorial, nos restringimos a um tipo simples e generalizado de unidade. Depois disso, passaremos a apoiar combinações de vários tipos de unidades.

Esquadrão de pré-fabricados


Para trabalhar com esquadrões, crie um novo tipo de componente HexUnit. Por enquanto, vamos começar com um vazio MonoBehavioure, posteriormente, adicionar funcionalidade a ele.

 using UnityEngine; public class HexUnit : MonoBehaviour { } 

Crie um objeto de jogo vazio com este componente, que deve se tornar uma pré-fabricada. Este será o objeto raiz do esquadrão.


Esquadrão de pré-fabricados.

Adicione um modelo 3D simbolizando o desapego como um objeto filho. Usei um cubo em escala simples para o qual criei material azul. O objeto raiz determina o nível do solo do desapego; portanto, substituímos o elemento filho.



Elemento do cubo filho

Adicione um colisor ao esquadrão para facilitar a seleção no futuro. O colisor do cubo padrão é bastante adequado para nós, basta fazer o colisor caber em uma célula.

Criando instâncias de esquadrão


Como ainda não temos jogabilidade, a criação de unidades ocorre no modo de edição. Portanto, isso deve ser resolvido HexMapEditor. Para fazer isso, ele precisa de uma pré-fabricada, então adicione um campo HexUnit unitPrefabe conecte-o.

  public HexUnit unitPrefab; 


Conectando a pré-fabricada

Ao criar unidades, as colocaremos na célula abaixo do cursor. Existe HandleInputum código para encontrar essa célula ao editar um terreno. Agora também precisamos dele para os esquadrões, para que movamos o código correspondente para um método separado.

  HexCell GetCellUnderCursor () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { return hexGrid.GetCell(hit.point); } return null; } 

Agora podemos usar esse método para HandleInputsimplificá-lo.

  void HandleInput () { // Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); // RaycastHit hit; // if (Physics.Raycast(inputRay, out hit)) { // HexCell currentCell = hexGrid.GetCell(hit.point); HexCell currentCell = GetCellUnderCursor(); if (currentCell) { … } else { previousCell = null; } } 

Em seguida, adicione um novo método CreateUnitque também use GetCellUnderCursor. Se houver uma célula, criaremos um novo esquadrão.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { Instantiate(unitPrefab); } } 

Para manter a hierarquia limpa, vamos usar a grade como pai de todos os objetos do jogo nos esquadrões.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); } } 

A maneira mais fácil de adicionar HexMapEditorsuporte à criação de unidades é pressionando uma tecla. Altere o método Updatepara que ele chame CreateUnitquando você pressiona a tecla U. Assim como em c HandleInput, isso deve acontecer se o cursor não estiver sobre o elemento GUI. Primeiro, verificaremos se devemos editar o mapa e, se não, verificaremos se devemos adicionar um esquadrão. Se sim, então ligue CreateUnit.

  void Update () { // if ( // Input.GetMouseButton(0) && // !EventSystem.current.IsPointerOverGameObject() // ) { // HandleInput(); // } // else { // previousCell = null; // } if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButton(0)) { HandleInput(); return; } if (Input.GetKeyDown(KeyCode.U)) { CreateUnit(); return; } } previousCell = null; } 


Instância criada do esquadrão

Colocação de tropas


Agora podemos criar unidades, mas elas aparecem na origem do mapa. Precisamos colocá-los no lugar certo. Para isso, é necessário que as tropas estejam cientes de sua posição. Portanto, adicionamos à HexUnitpropriedade Locationdenotando a célula que eles ocupam. Ao definir a propriedade, alteraremos a posição do esquadrão para que ele corresponda à posição da célula.

  public HexCell Location { get { return location; } set { location = value; transform.localPosition = value.Position; } } HexCell location; 

Agora devo HexMapEditor.CreateUnitatribuir a posição da célula de esquadrão sob o cursor. Então as unidades estarão onde deveriam.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); unit.Location = cell; } } 


Esquadrões no mapa

Orientação da unidade


Até agora, todas as unidades têm a mesma orientação, o que parece pouco natural. Para apimentar as coisas, aumentando a HexUnitpropriedade Orientation. Este é um valor flutuante que indica a rotação do esquadrão ao longo do eixo Y em graus. Ao configurá-lo, alteraremos a rotação do próprio objeto do jogo.

  public float Orientation { get { return orientation; } set { orientation = value; transform.localRotation = Quaternion.Euler(0f, value, 0f); } } float orientation; 

Em HexMapEditor.CreateUnitatribuir uma rotação aleatória de 0 a 360 graus.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell) { HexUnit unit = Instantiate(unitPrefab); unit.transform.SetParent(hexGrid.transform, false); unit.Location = cell; unit.Orientation = Random.Range(0f, 360f); } } 


Diferentes orientações da unidade

Um esquadrão por célula


As unidades ficam bem se não forem criadas em uma célula. Nesse caso, temos um conjunto de cubos de aparência estranha.


Unidades sobrepostas

Alguns jogos permitem a colocação de várias unidades em um só lugar, outros não. Como é mais fácil trabalhar com um esquadrão por célula, escolherei essa opção. Isso significa que devemos criar um novo esquadrão apenas quando a célula atual não estiver ocupada. Para que você possa descobrir, adicione à HexCellpropriedade padrão Unit.

  public HexUnit Unit { get; set; } 

Usamos essa propriedade HexUnit.Locationpara informar à célula se a unidade está nela.

  public HexCell Location { get { return location; } set { location = value; value.Unit = this; transform.localPosition = value.Position; } } 

Agora ele HexMapEditor.CreateUnitpode verificar se a célula atual está livre.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { HexUnit unit = Instantiate(unitPrefab); unit.Location = cell; unit.Orientation = Random.Range(0f, 360f); } } 

Editando células ocupadas


Inicialmente, as unidades são colocadas corretamente, mas tudo pode mudar se suas células forem editadas posteriormente. Se a altura da célula mudar, a unidade que a ocupar ficará pendurada acima dela ou mergulhará nela.


Esquadrões pendurados e afogados

A solução é verificar a posição do esquadrão depois de fazer alterações. Para fazer isso, adicione o método a HexUnit. Até agora, estamos interessados ​​apenas na posição da equipe, então pergunte novamente.

  public void ValidateLocation () { transform.localPosition = location.Position; } 

Devemos coordenar a posição do desapego ao atualizar a célula, o que acontece quando os métodos Refreshou RefreshSelfOnlyobjetos são HexCellchamados. Obviamente, isso é necessário apenas quando realmente existe um desapego na célula.

  void Refresh () { if (chunk) { chunk.Refresh(); … if (Unit) { Unit.ValidateLocation(); } } } void RefreshSelfOnly () { chunk.Refresh(); if (Unit) { Unit.ValidateLocation(); } } 

Remoção de esquadrões


Além de criar unidades, seria útil destruí-las. Portanto, nós adicionamos o HexMapEditormétodo DestroyUnit. Ele deve verificar se há um desapego na célula sob o cursor e, se houver, destruir o objeto de jogo do desapego.

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { Destroy(cell.Unit.gameObject); } } 

Observe que, para chegar ao esquadrão, passamos pela célula. Para interagir com o esquadrão, basta passar o mouse sobre sua célula. Portanto, para que isso funcione, o esquadrão não precisa ter um colisor. No entanto, adicionar um colisor facilita a seleção porque bloqueia os raios que, de outra forma, colidiriam com a célula atrás do esquadrão.

Vamos Updateusar uma combinação de Shift + U esquerdo para destruir o esquadrão .

  if (Input.GetKeyDown(KeyCode.U)) { if (Input.GetKey(KeyCode.LeftShift)) { DestroyUnit(); } else { CreateUnit(); } return; } 

No caso em que criamos e destruímos várias unidades, tenha cuidado e limpe a propriedade ao remover a unidade. Ou seja, limpamos explicitamente o link da célula para o esquadrão. Adicione ao HexUnitmétodo Dieque lida com isso, bem como a destruição do seu próprio objeto de jogo.

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

Vamos chamar esse método de HexMapEditor.DestroyUnit, e não destruir o esquadrão diretamente.

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { // Destroy(cell.Unit.gameObject); cell.Unit.Die(); } } 

unitypackage

Salvando e carregando esquadrões


Agora que podemos ter unidades no mapa, precisamos incluí-las no processo de salvar e carregar. Podemos abordar essa tarefa de duas maneiras. O primeiro é gravar dados do esquadrão ao gravar uma célula, para que os dados da célula e do esquadrão sejam misturados. A segunda maneira é salvar os dados de célula e esquadrão separadamente. Embora possa parecer que a primeira abordagem seja mais fácil de implementar, a segunda nos fornece dados mais estruturados. Se compartilharmos os dados, será mais fácil trabalhar com eles no futuro.

Rastreamento de unidade


Para manter todas as unidades juntas, precisamos rastreá-las. Faremos isso adicionando à HexGridlista de unidades. Esta lista deve conter todas as unidades no mapa.

  List<HexUnit> units = new List<HexUnit>(); 

Ao criar ou carregar um novo mapa, precisamos nos livrar de todas as unidades no mapa. Para simplificar esse processo, crie um método ClearUnitsque mate todos na lista e apague-o.

  void ClearUnits () { for (int i = 0; i < units.Count; i++) { units[i].Die(); } units.Clear(); } 

Chamamos esse método dentro CreateMape dentro Load. Vamos fazer isso depois de limpar o caminho.

  public bool CreateMap (int x, int z) { … ClearPath(); ClearUnits(); … } … public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); … } 

Adicionando esquadrões à grade


Agora, ao criar novas unidades, precisamos adicioná-las à lista. Vamos definir um método para isso AddUnit, que também irá lidar com a localização do esquadrão e os parâmetros do seu objeto pai.

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

Agora HexMapEditor.CreatUnitserá suficiente chamar AddUnitcom uma nova instância do desapego, sua localização e orientação aleatória.

  void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { // HexUnit unit = Instantiate(unitPrefab); // unit.transform.SetParent(hexGrid.transform, false); // unit.Location = cell; // unit.Orientation = Random.Range(0f, 360f); hexGrid.AddUnit( Instantiate(unitPrefab), cell, Random.Range(0f, 360f) ); } } 

Removendo esquadrões da grade


Adicione um método para remover o esquadrão e c HexGrid. Apenas remova o esquadrão da lista e ordene que ele morra.

  public void RemoveUnit (HexUnit unit) { units.Remove(unit); unit.Die(); } 

Chamamos esse método de HexMapEditor.DestroyUnit, em vez de destruir o esquadrão diretamente.

  void DestroyUnit () { HexCell cell = GetCellUnderCursor(); if (cell && cell.Unit) { // cell.Unit.Die(); hexGrid.RemoveUnit(cell.Unit); } } 

Salvando unidades


Como vamos manter todas as unidades juntas, precisamos lembrar quais células elas ocupam. A maneira mais confiável é salvar as coordenadas de sua localização. Para tornar isso possível, adicionamos os campos X e Z ao HexCoordinatesmétodo Saveque o escreve.

 using UnityEngine; using System.IO; [System.Serializable] public struct HexCoordinates { … public void Save (BinaryWriter writer) { writer.Write(x); writer.Write(z); } } 

O método Savepara HexUnitagora pode gravar as coordenadas e a orientação do esquadrão. Esses são todos os dados das unidades que temos no momento.

 using UnityEngine; using System.IO; public class HexUnit : MonoBehaviour { … public void Save (BinaryWriter writer) { location.coordinates.Save(writer); writer.Write(orientation); } } 

Como HexGridrastreia unidades, seu método Saveregistra os dados das unidades. Primeiro, anote o número total de unidades e, em seguida, contorne todas elas em um loop.

  public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } writer.Write(units.Count); for (int i = 0; i < units.Count; i++) { units[i].Save(writer); } } 

Como alteramos os dados armazenados, aumentamos o número da versão SaveLoadMenu.Savepara 2. O código de inicialização antigo ainda funcionará, porque simplesmente não lê os dados do esquadrão. No entanto, você precisa aumentar o número da versão para indicar que há informações da unidade no arquivo.

  void Save (string path) { using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(2); hexGrid.Save(writer); } } 

Carregando esquadrões


Como HexCoordinatesé uma estrutura, não faz muito sentido adicionar o método usual a ela Load. Vamos torná-lo um método estático que lê e retorna coordenadas armazenadas.

  public static HexCoordinates Load (BinaryReader reader) { HexCoordinates c; cx = reader.ReadInt32(); cz = reader.ReadInt32(); return c; } 

Como o número de unidades é variável, não temos unidades pré-existentes nas quais os dados podem ser carregados. Podemos criar novas instâncias de unidades antes de carregar seus dados, mas isso exigirá a HexGridcriação de instâncias de novas unidades no momento da inicialização. Então é melhor deixar isso HexUnit. Também usamos o método estático HexUnit.Load. Vamos começar simplesmente lendo esses esquadrões. Para ler o valor de orientações flutuador usar o método BinaryReader.ReadSingle.

Por que solteiro?
float , . , double , . Unity .

  public static void Load (BinaryReader reader) { HexCoordinates coordinates = HexCoordinates.Load(reader); float orientation = reader.ReadSingle(); } 

O próximo passo é criar uma instância de um novo esquadrão. No entanto, para isso, precisamos de um link para a pré-fabricada da unidade. Para não complicar ainda, vamos adicionar um HexUnitmétodo estático para isso .

  public static HexUnit unitPrefab; 

Para definir esse link, vamos usá-lo novamente HexGrid, como fizemos com a textura do ruído. Quando precisarmos oferecer suporte a muitos tipos de unidades, seguiremos para uma solução melhor.

  public HexUnit unitPrefab; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; CreateMap(cellCountX, cellCountZ); } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; } } 


Passamos pela pré-fabricação da unidade.

Após conectar o campo, não precisamos mais de um link direto para HexMapEditor. Em vez disso, ele pode usar HexUnit.unitPrefab.

 // public HexUnit unitPrefab; … void CreateUnit () { HexCell cell = GetCellUnderCursor(); if (cell && !cell.Unit) { hexGrid.AddUnit( Instantiate(HexUnit.unitPrefab), cell, Random.Range(0f, 360f) ); } } 

Agora podemos criar uma instância do novo esquadrão HexUnit.Load. Em vez de devolvê-lo, podemos usar as coordenadas e a orientação carregadas para adicioná-lo à grade. Para tornar isso possível, adicione um parâmetro HexGrid.

  public static void Load (BinaryReader reader, HexGrid grid) { HexCoordinates coordinates = HexCoordinates.Load(reader); float orientation = reader.ReadSingle(); grid.AddUnit( Instantiate(unitPrefab), grid.GetCell(coordinates), orientation ); } 

No final, HexGrid.Loadcontamos o número de unidades e o usamos para carregar todas as unidades armazenadas, passando-nos como um argumento adicional.

  public void Load (BinaryReader reader, int header) { … int unitCount = reader.ReadInt32(); for (int i = 0; i < unitCount; i++) { HexUnit.Load(reader, this); } } 

Obviamente, isso funcionará apenas para salvar arquivos com versão não inferior a 2, nas versões mais novas não há unidades para carregar.

  if (header >= 2) { int unitCount = reader.ReadInt32(); for (int i = 0; i < unitCount; i++) { HexUnit.Load(reader, this); } } 

Agora, podemos fazer o upload correto dos arquivos da versão 2, SaveLoadMenu.Loadaumentando o número da versão suportada para 2.

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

unitypackage

Movimento de Tropa


Os esquadrões são móveis, por isso devemos ser capazes de movê-los pelo mapa. Já temos um código de busca de caminho, mas até agora o testamos apenas para locais arbitrários. Agora precisamos remover a interface do usuário de teste antiga e criar uma nova interface para o gerenciamento de esquadrão.

Limpeza do Editor de Mapas


Mover unidades ao longo de caminhos faz parte da jogabilidade, não se aplica ao editor de mapas. Portanto, nos livraremos HexMapEditorde todo o código associado à localização do caminho.

 // HexCell previousCell, searchFromCell, searchToCell; HexCell previousCell; … void HandleInput () { HexCell currentCell = GetCellUnderCursor(); if (currentCell) { if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } if (editMode) { EditCells(currentCell); } // else if ( // Input.GetKey(KeyCode.LeftShift) && searchToCell != currentCell // ) { // if (searchFromCell != currentCell) { // if (searchFromCell) { // searchFromCell.DisableHighlight(); // } // searchFromCell = currentCell; // searchFromCell.EnableHighlight(Color.blue); // if (searchToCell) { // hexGrid.FindPath(searchFromCell, searchToCell, 24); // } // } // } // else if (searchFromCell && searchFromCell != currentCell) { // if (searchToCell != currentCell) { // searchToCell = currentCell; // hexGrid.FindPath(searchFromCell, searchToCell, 24); // } // } previousCell = currentCell; } else { previousCell = null; } } 

Depois de remover esse código, não faz mais sentido deixar o editor ativo quando não estamos no modo de edição. Portanto, em vez de um campo de rastreamento de modo, podemos simplesmente ativar ou desativar o componente HexMapEditor. Além disso, o editor agora não precisa lidar com os rótulos da interface do usuário.

 // bool editMode; … public void SetEditMode (bool toggle) { // editMode = toggle; // hexGrid.ShowUI(!toggle); enabled = toggle; } … void HandleInput () { HexCell currentCell = GetCellUnderCursor(); if (currentCell) { if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } // if (editMode) { EditCells(currentCell); // } previousCell = currentCell; } else { previousCell = null; } } 

Como, por padrão, não estamos no modo de edição de mapas, no Desperto, desativaremos o editor.

  void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); SetEditMode(false); } 

Use o raycast para procurar a célula atual sob o cursor e necessário ao editar o mapa e gerenciar unidades. Talvez no futuro nos seja útil para outra coisa. Vamos passar a lógica do raycasting de HexGridpara um novo método GetCellcom um parâmetro de feixe.

  public HexCell GetCell (Ray ray) { RaycastHit hit; if (Physics.Raycast(ray, out hit)) { return GetCell(hit.point); } return null; } 

HexMapEditor.GetCellUniderCursor pode apenas chamar esse método com o feixe do cursor.

  HexCell GetCellUnderCursor () { return hexGrid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); } 

UI do jogo


Para controlar a interface do usuário do modo de jogo, usaremos um novo componente. Enquanto ele só vai lidar com a seleção e movimentação de unidades. Crie um novo tipo de componente para ele HexGameUI. Para fazer seu trabalho, basta um link para a rede.

 using UnityEngine; using UnityEngine.EventSystems; public class HexGameUI : MonoBehaviour { public HexGrid grid; } 

Adicione este componente ao novo objeto de jogo na hierarquia da interface do usuário. Ele não precisa ter seu próprio objeto, mas será óbvio para nós que existe uma interface do usuário separada para o jogo.



Objeto de interface do jogo do jogo

Adicione um HexGameUImétodo SetEditMode, como em HexMapEditor. A interface do jogo deve ser ativada quando não estamos no modo de edição. Além disso, os rótulos precisam ser incluídos aqui, porque a interface do usuário do jogo funciona com caminhos.

  public void SetEditMode (bool toggle) { enabled = !toggle; grid.ShowUI(!toggle); } 

Adicione o método da interface do jogo à lista de eventos da opção do modo de edição. Isso significa que quando o jogador muda de modo, os dois métodos são chamados.


Vários métodos de eventos.

Rastrear célula atual


Dependendo da situação, HexGameUIvocê precisa saber qual célula está atualmente sob o cursor. Portanto, adicionamos um campo a ele currentCell.

  HexCell currentCell; 

Crie um método UpdateCurrentCellque use a HexGrid.GetCellviga do cursor para atualizar este campo.

  void UpdateCurrentCell () { currentCell = grid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); } 

Ao atualizar a célula atual, podemos precisar descobrir se ela mudou. Forçar a UpdateCurrentCellretornar essas informações.

  bool UpdateCurrentCell () { HexCell cell = grid.GetCell(Camera.main.ScreenPointToRay(Input.mousePosition)); if (cell != currentCell) { currentCell = cell; return true; } return false; } 

Seleção de Unidade


Antes de mover um esquadrão, ele deve ser selecionado e rastreado. Portanto, adicione um campo selectedUnit.

  HexUnit selectedUnit; 

Quando tentamos fazer uma seleção, precisamos começar atualizando a célula atual. Se a célula atual for, a unidade que ocupa essa célula se torna a unidade selecionada. Se não houver nenhuma unidade na célula, nenhuma unidade será selecionada. Vamos criar um método para isso DoSelection.

  void DoSelection () { UpdateCurrentCell(); if (currentCell) { selectedUnit = currentCell.Unit; } } 

Percebemos a escolha das unidades com um simples clique do mouse. Portanto, adicionamos um método Updateque faz uma seleção quando o botão do mouse é ativado.Claro, precisamos executá-lo apenas quando o cursor não está acima do elemento GUI.

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } } } 

Nesta fase, aprendemos como selecionar uma unidade de cada vez com o clique de um mouse. Quando você clica em uma célula vazia, a seleção de qualquer unidade é removida. Mas enquanto não recebemos nenhuma confirmação visual disso.

Pesquisa de esquadrão


Quando uma unidade é selecionada, podemos usar sua localização como ponto de partida para encontrar um caminho. Para ativar isso, não será necessário outro clique no botão do mouse. Em vez disso, encontraremos e mostraremos automaticamente o caminho entre a posição do esquadrão e a célula atual. Sempre faremos isso Update, exceto quando a escolha for feita. Para fazer isso, quando temos um plantel, chamamos o método DoPathfinding.

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } else if (selectedUnit) { DoPathfinding(); } } } 

DoPathfindingapenas atualiza a célula atual e chama HexGrid.FindPathse houver um ponto de extremidade. Novamente, usamos uma velocidade constante de 24.

  void DoPathfinding () { UpdateCurrentCell(); grid.FindPath(selectedUnit.Location, currentCell, 24); } 

Observe que não devemos encontrar um novo caminho toda vez que atualizamos, mas somente quando a célula atual é alterada.

  void DoPathfinding () { if (UpdateCurrentCell()) { grid.FindPath(selectedUnit.Location, currentCell, 24); } } 


Localizando um caminho para um esquadrão

Agora, vemos os caminhos que aparecem quando você move o cursor após selecionar um esquadrão. Graças a isso, é óbvio qual unidade está selecionada. No entanto, os caminhos nem sempre são limpos corretamente. Primeiro, vamos limpar o caminho antigo se o cursor estiver fora do mapa.

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

Obviamente, isso exige que HexGrid.ClearPathseja comum, então fazemos essa alteração.

  public void ClearPath () { … } 

Em segundo lugar, limparemos o caminho antigo ao escolher um desapego.

  void DoSelection () { grid.ClearPath(); UpdateCurrentCell(); if (currentCell) { selectedUnit = currentCell.Unit; } } 

Por fim, limparemos o caminho ao alterar o modo de edição.

  public void SetEditMode (bool toggle) { enabled = !toggle; grid.ShowUI(!toggle); grid.ClearPath(); } 

Procure apenas pontos de extremidade válidos


Nem sempre podemos encontrar o caminho, porque às vezes é impossível chegar à célula final. Isso é normal. Mas às vezes a própria célula final é inaceitável. Por exemplo, decidimos que os caminhos não podem incluir células subaquáticas. Mas isso pode depender da unidade. Vamos adicionar a um HexUnitmétodo que nos diz se uma célula é um ponto de extremidade válido. As células subaquáticas não são.

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

Além disso, permitimos que apenas uma unidade permanecesse na célula. Portanto, a célula final não será válida se estiver ocupada.

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

Usamos esse método HexGameUI.DoPathfindingpara ignorar pontos de extremidade inválidos.

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

Mover para o ponto final


Se tivermos um caminho válido, poderemos mover o esquadrão para o ponto final. HexGridsabe quando isso pode ser feito. Fazemos passar essas informações em uma nova propriedade somente leitura HasPath.

  public bool HasPath { get { return currentPathExists; } } 

Para mover um esquadrão, adicione ao HexGameUImétodo DoMove. Este método será chamado quando um comando for emitido e se uma unidade for selecionada. Portanto, ele deve verificar se existe uma maneira e, se houver, alterar a localização do destacamento. Enquanto teletransportamos imediatamente o esquadrão para o ponto final. Em um dos tutoriais a seguir, faremos com que o esquadrão vá até o fim.

  void DoMove () { if (grid.HasPath) { selectedUnit.Location = currentCell; grid.ClearPath(); } } 

Vamos usar o botão 1 do mouse (clique direito) para enviar o comando. Verificaremos isso se um destacamento for selecionado. Se o botão não for pressionado, procuraremos o caminho.

  void Update () { if (!EventSystem.current.IsPointerOverGameObject()) { if (Input.GetMouseButtonDown(0)) { DoSelection(); } else if (selectedUnit) { if (Input.GetMouseButtonDown(1)) { DoMove(); } else { DoPathfinding(); } } } } 

Agora podemos mover unidades! Mas, às vezes, eles se recusam a encontrar um caminho para algumas células. Em particular, naquelas células em que costumava estar o desapego. Isso acontece porque ele HexUnitnão atualiza o local antigo ao definir um novo. Para consertar isso, limparemos o link para o esquadrão em seu antigo local.

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

Evite esquadrões


Encontrar o caminho agora funciona corretamente e as unidades podem se teletransportar no mapa. Embora eles não possam se mudar para células que já possuem um esquadrão, os destacamentos no caminho são ignorados.


As unidades no caminho são ignoradas: as

unidades da mesma facção geralmente podem se mover, mas até agora não temos facções. Portanto, vamos considerar todas as unidades desconectadas uma da outra e bloqueando os caminhos. Isso pode ser implementado pulando células ocupadas HexGrid.Search.

  if ( neighbor == null || neighbor.SearchPhase > searchFrontierPhase ) { continue; } if (neighbor.IsUnderwater || neighbor.Unit) { continue; } 


Evitar destacamentos

unitypackage

Parte 19: Animação em Movimento


  • Movemos as unidades entre as células.
  • Visualize o caminho percorrido.
  • Nós movemos as tropas ao longo das curvas.
  • Nós forçamos as tropas a olhar na direção do movimento.

Nesta parte, forçaremos as unidades em vez do teletransporte a se moverem ao longo dos trilhos.


Esquadrões a caminho

Movimento ao longo do caminho


Na parte anterior, adicionamos unidades e a capacidade de movê-las. Embora tenhamos usado a busca do caminho para determinar os pontos finais válidos, depois de dar o comando, as tropas simplesmente se teletransportaram para a célula final. Para realmente seguir o caminho encontrado, precisamos rastrear esse caminho e criar um processo de animação que force o esquadrão a se mover de célula em célula. Já que, olhando as animações, é difícil perceber como o esquadrão se moveu, também visualizamos o caminho percorrido com a ajuda de aparelhos. Mas antes de seguirmos em frente, precisamos corrigir o erro.

Erro nas voltas


Devido a uma supervisão, calculamos incorretamente o curso em que a célula será atingida. Agora, determinamos o percurso dividindo a distância total pela velocidade do esquadrãot = d / s e descartando o restante. O erro ocorre quando, para entrar na célula, você precisa gastar exatamente todos os pontos de movimento restantes por movimento. Por exemplo, quando cada passo custa 1 e a velocidade é 3, podemos mover três células por turno. No entanto, com os cálculos existentes, só podemos dar dois passos no primeiro passo, porque no terceiro passo

t = d / s = 3 / 3 = 1 .


Os custos somados da movimentação com movimentos definidos incorretamente, velocidade 3

Para o cálculo correto dos movimentos, precisamos mover a borda um passo a partir da célula inicial. Podemos fazer isso reduzindo a distância em 1 antes de calcular o movimento, e o movimento para o terceiro passo serát = 2 / 3 = 0


Movimentos corretos

Podemos fazer isso alterando a fórmula de cálculo parat = ( d - 1 ) / s .Faremos essa alteração em HexGrid.Search.

  bool Search (HexCell fromCell, HexCell toCell, int speed) { … while (searchFrontier.Count > 0) { … int currentTurn = (current.Distance - 1) / speed; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int distance = current.Distance + moveCost; int turn = (distance - 1) / speed; if (turn > currentTurn) { distance = turn * speed + moveCost; } … } } return false; } 

Também mudamos as marcas dos movimentos.

  void ShowPath (int speed) { if (currentPathExists) { HexCell current = currentPathTo; while (current != currentPathFrom) { int turn = (current.Distance - 1) / speed; … } } … } 

Observe que, com essa abordagem, o caminho inicial da célula é -1. Isso é normal, porque não o exibimos e o algoritmo de busca permanece operacional.

Ficando maneira


Movendo-se ao longo do caminho é a tarefa do esquadrão. Para ele fazer isso, ele precisa saber o caminho. Como temos essas informações HexGrid, vamos adicionar um método para obter o caminho atual na forma de uma lista de células. Ele pode retirá-lo do pool de listas e retornar se realmente houver um caminho.

  public List<HexCell> GetPath () { if (!currentPathExists) { return null; } List<HexCell> path = ListPool<HexCell>.Get(); return path; } 

A lista é preenchida seguindo o caminho do link da célula final até a inicial, como é feito ao visualizar o caminho.

  List<HexCell> path = ListPool<HexCell>.Get(); for (HexCell c = currentPathTo; c != currentPathFrom; c = c.PathFrom) { path.Add(c); } return path; 

Nesse caso, precisamos de todo o caminho, que inclui a célula inicial.

  for (HexCell c = currentPathTo; c != currentPathFrom; c = c.PathFrom) { path.Add(c); } path.Add(currentPathFrom); return path; 

Agora temos o caminho na ordem inversa. Podemos trabalhar com ele, mas não será muito intuitivo. Vamos virar a lista para que ela vá do começo ao fim.

  path.Add(currentPathFrom); path.Reverse(); return path; 

Solicitação de movimento


Agora podemos adicionar ao HexUnitmétodo, ordenando que ele siga o caminho. Inicialmente, simplesmente deixamos que ele se teletransportasse para a célula final. Não retornaremos imediatamente a lista à piscina, porque ela será útil por um tempo.

 using UnityEngine; using System.Collections.Generic; using System.IO; public class HexUnit : MonoBehaviour { … public void Travel (List<HexCell> path) { Location = path[path.Count - 1]; } … } 

Para solicitar movimento, alteramos HexGameUI.DoMovepara que ele chame um novo método com o caminho atual, e não apenas defina a localização da unidade.

  void DoMove () { if (grid.HasPath) { // selectedUnit.Location = currentCell; selectedUnit.Travel(grid.GetPath()); grid.ClearPath(); } } 

Visualização de caminho


Antes de começarmos a animar o esquadrão, vamos verificar se os caminhos estão corretos. Faremos isso ordenando HexUnitlembrar o caminho ao longo do qual ele deve se mover, para que possa ser visualizado usando dispositivos.

  List<HexCell> pathToTravel; … public void Travel (List<HexCell> path) { Location = path[path.Count - 1]; pathToTravel = path; } 

Adicione um método OnDrawGizmospara mostrar o último caminho a percorrer (se existir). Se a unidade ainda não se moveu, o caminho deve ser igual null. Porém, devido à serialização do Unity durante a edição após a recompilação no modo Play, também pode ser uma lista vazia.

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } } 

A maneira mais fácil de mostrar o caminho é desenhar uma esfera de dispositivo para cada célula do caminho. Uma esfera com um raio de 2 unidades é adequada para nós.

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } for (int i = 0; i < pathToTravel.Count; i++) { Gizmos.DrawSphere(pathToTravel[i].Position, 2f); } } 

Como mostraremos os caminhos para o desapego, poderemos ver simultaneamente todos os seus últimos caminhos.


Os aparelhos exibem os últimos caminhos percorridos.Para

mostrar melhor as conexões das células, desenhamos várias esferas em um loop em uma linha entre as células anteriores e as atuais. Para fazer isso, precisamos iniciar o processo a partir da segunda célula. As esferas podem ser organizadas usando interpolação linear com um incremento de 0,1 unidades, para que possamos obter dez esferas por segmento.

  for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } 


Maneiras mais óbvias

Deslize ao longo do caminho


Você pode usar o mesmo método para mover unidades. Vamos criar uma rotina para isso. Em vez de desenhar um dispositivo, definiremos a posição do esquadrão. Em vez de incrementar, usaremos 0,1 delta de tempo e executaremos o rendimento para cada iteração. Nesse caso, o esquadrão passará de uma célula para a próxima em um segundo.

 using UnityEngine; using System.Collections; using System.Collections.Generic; using System.IO; public class HexUnit : MonoBehaviour { … IEnumerator TravelPath () { for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += Time.deltaTime) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } } … } 

Vamos começar a rotina no final do método Travel. Mas primeiro, pararemos todas as corotinas existentes. Portanto, garantimos que duas corotinas não serão iniciadas ao mesmo tempo, caso contrário, isso levaria a resultados muito estranhos.

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

Mover uma célula por segundo é bem lento. O jogador durante o jogo não vai querer esperar tanto tempo. Você pode fazer com que a velocidade de movimento do esquadrão seja uma opção de configuração, mas, por enquanto, vamos usar uma constante. Eu atribuí a ela um valor de 4 células por segundo; é bem rápido, mas vamos notar o que está acontecendo.

  const float travelSpeed = 4f; … IEnumerator TravelPath () { for (int i = 1; i < pathToTravel.Count; i++) { Vector3 a = pathToTravel[i - 1].Position; Vector3 b = pathToTravel[i].Position; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } } 

Assim como podemos visualizar vários caminhos simultaneamente, também podemos fazer com que várias unidades viajem ao mesmo tempo. Do ponto de vista do estado do jogo, o movimento ainda é o teletransporte, as animações são exclusivamente visuais. As unidades ocupam instantaneamente a célula final. Você pode até encontrar maneiras e iniciar uma nova jogada antes que elas cheguem. Nesse caso, eles são teleportados visualmente para o início de um novo caminho. Isso pode ser evitado através do bloqueio de unidades ou mesmo de toda a interface do usuário enquanto elas estão em movimento, mas uma reação tão rápida é bastante conveniente ao desenvolver e testar movimentos.


Unidades móveis.

E a diferença de altura?
, . , . , . , . , Endless Legend, , . , .

Posição após a compilação


Uma das desvantagens do corutin é que eles não "sobrevivem" quando recompilados no modo Play. Embora o estado do jogo seja sempre verdadeiro, isso pode fazer com que os esquadrões fiquem presos em algum lugar do último caminho, se a recompilação for iniciada enquanto eles ainda estão em movimento. Para mitigar as consequências, vamos garantir que, após a recompilação, as unidades estejam sempre na posição correta. Isso pode ser feito atualizando sua posição em OnEnable.

  void OnEnable () { if (location) { transform.localPosition = location.Position; } } 

unitypackage

Movimento suave


O movimento do centro para o centro da célula parece muito mecanicista e cria mudanças bruscas de direção. Para muitos jogos, isso será normal, mas inaceitável se você precisar de pelo menos um movimento levemente realista. Então, vamos mudar o movimento para torná-lo um pouco mais orgânico.

Movendo-se de costela para costela


O esquadrão começa sua jornada a partir do centro da célula. Passa para o meio da borda da célula e entra na próxima célula. Em vez de se mover em direção ao centro, ele pode ir direto para a próxima borda que deve atravessar. De fato, a unidade cortará o caminho quando precisar mudar de direção. Isso é possível para todas as células, exceto os pontos finais do caminho.


Três maneiras de se mover de ponta a ponta

Vamos nos adaptar OnDrawGizmospara exibir os caminhos gerados dessa maneira. Ele deve interpolar entre as bordas das células, o que pode ser encontrado pela média das posições das células vizinhas. É o suficiente para calcular uma borda por iteração, reutilizando o valor da iteração anterior. Assim, podemos fazer o método funcionar para a célula inicial, mas, em vez da borda, assumimos sua posição.

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } Vector3 a, b = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { // Vector3 a = pathToTravel[i - 1].Position; // Vector3 b = pathToTravel[i].Position; a = b; b = (pathToTravel[i - 1].Position + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } } 

Para alcançar o centro da célula final, precisamos usar a posição da célula como o último ponto, não a aresta. Você pode adicionar a verificação desse caso ao loop, mas é um código tão simples que será mais óbvio duplicar o código e alterá-lo levemente.

  void OnDrawGizmos () { … for (int i = 1; i < pathToTravel.Count; i++) { … } a = b; b = pathToTravel[pathToTravel.Count - 1].Position; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Vector3.Lerp(a, b, t), 2f); } } 




Caminhos baseados em nervuras Os caminhos resultantes são menos parecidos com ziguezagues e o ângulo de viragem máximo é reduzido de 120 ° para 90 °. Isso pode ser considerado uma melhoria; portanto, aplicamos as mesmas alterações na corotina TravelPathpara ver como fica a animação.

  IEnumerator TravelPath () { Vector3 a, b = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { // Vector3 a = pathToTravel[i - 1].Position; // Vector3 b = pathToTravel[i].Position; a = b; b = (pathToTravel[i - 1].Position + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } a = b; b = pathToTravel[pathToTravel.Count - 1].Position; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Vector3.Lerp(a, b, t); yield return null; } } 


Movendo-se com uma velocidade variável

Depois de cortar os ângulos, o comprimento dos segmentos do caminho ficou dependente da mudança de direção. Mas ajustamos a velocidade nas células por segundo. Como resultado, a velocidade do destacamento muda aleatoriamente.

Seguindo curvas


Mudanças instantâneas de direção e velocidade ao cruzar os limites das células parecem feias. Melhor usar uma mudança gradual de direção. Podemos acrescentar apoio forçando as tropas a seguirem curvas, em vez de linhas retas. Você pode usar curvas de Bezier para isso. Em particular, podemos fazer curvas quadráticas de Bezier nas quais o centro das células será o ponto de controle intermediário. Nesse caso, as tangentes das curvas adjacentes serão imagens em espelho uma da outra, ou seja, todo o caminho se transformará em uma curva suave contínua.


Curvas de ponta a ponta

Crie uma classe auxiliar Beziercom um método para obter pontos em uma curva quadrática de Bezier. Conforme explicado no tutorial Curvas e splines , a fórmula é usada para este( 1 - t ) 2 A + 2 ( 1 - t ) t B + t 2 C onde Um , B e C são os pontos de controle e t é o interpolador.

 using UnityEngine; public static class Bezier { public static Vector3 GetPoint (Vector3 a, Vector3 b, Vector3 c, float t) { float r = 1f - t; return r * r * a + 2f * r * t * b + t * t * c; } } 

O GetPoint não deve ser limitado a 0-1?
0-1, . . , GetPointClamped , t . , GetPointUnclamped .

Para mostrar o caminho da curva OnDrawGizmos, precisamos rastrear não dois, mas três pontos. Um ponto adicional é o centro da célula com a qual estamos trabalhando na iteração atual, que possui um índice i - 1, porque o ciclo começa com 1. Após receber todos os pontos, podemos substituí-lo Vector3.Lerppor Bezier.GetPoint.

Nas células inicial e final, em vez dos pontos final e médio, podemos simplesmente usar o centro da célula.

  void OnDrawGizmos () { if (pathToTravel == null || pathToTravel.Count == 0) { return; } Vector3 a, b, c = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { Gizmos.DrawSphere(Bezier.GetPoint(a, b, c, t), 2f); } } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (float t = 0f; t < 1f; t += 0.1f) { Gizmos.DrawSphere(Bezier.GetPoint(a, b, c, t), 2f); } } 


Caminhos criados usando curvas de Bezier Um

caminho curvo parece muito melhor. Aplicamos as mesmas alterações TravelPathe vemos como as unidades são animadas com essa abordagem.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (float t = 0f; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } 


Nos movemos pelas curvas e a

animação também se tornou suave, mesmo quando a velocidade do desapego é instável. Como as tangentes da curva dos segmentos adjacentes coincidem, a velocidade é contínua. A mudança na velocidade ocorre gradualmente e acontece quando um desapego passa pela célula, diminuindo a velocidade ao mudar de direção. Se ele seguir em frente, a velocidade permanecerá constante. Além disso, o esquadrão começa e termina sua jornada na velocidade zero. Isso imita o movimento natural, então deixe assim.

Rastreamento de tempo


Até esse ponto, começamos a iterar sobre cada um dos segmentos de 0, continuando até chegar a 1. Isso funciona bem ao aumentar por um valor constante, mas nossa iteração depende do delta do tempo. Quando a iteração em um segmento for concluída, é provável que exceda 1 em alguma quantidade, dependendo do tempo delta. Isso é invisível em altas taxas de quadros, mas pode levar a movimentos bruscos em baixas taxas de quadros.

Para evitar a perda de tempo, precisamos transferir o tempo restante de um segmento para o próximo. Isso pode ser feito rastreando to caminho inteiro, e não apenas em cada segmento. Então, no final de cada segmento, subtrairemos 1 dele.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; float t = 0f; for (int i = 1; i < pathToTravel.Count; i++) { a = c; b = pathToTravel[i - 1].Position; c = (b + pathToTravel[i].Position) * 0.5f; for (; t < 1f; t += Time.deltaTime * travelSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } t -= 1f; } a = c; b = pathToTravel[pathToTravel.Count - 1].Position; c = b; for (; t < 1f; t += Time.deltaTime * traveSpeed) { transform.localPosition = Bezier.GetPoint(a, b, c, t); yield return null; } } 

Se já estivermos fazendo isso, vamos garantir que o delta do tempo seja levado em consideração no início do caminho. Isso significa que começaremos a nos mover imediatamente e não ficaremos ociosos por um quadro.

  float t = Time.deltaTime * travelSpeed; 

Além disso, não terminamos exatamente no momento em que o caminho deve terminar, mas momentos antes. Aqui, a diferença também pode depender da taxa de quadros. Portanto, vamos fazer com que o esquadrão complete o caminho exatamente no ponto final.

  IEnumerator TravelPath () { … transform.localPosition = location.Position; } 

unitypackage

Animação de orientação


As unidades começaram a se mover ao longo de uma curva suave, mas não mudaram a orientação de acordo com a direção do movimento. Como resultado, eles parecem deslizar. Para fazer o movimento parecer um movimento real, precisamos rotacioná-los.

Olhando para frente


Como no tutorial Curvas e splines , podemos usar a derivada da curva para determinar a orientação da unidade. A fórmula para a derivada de uma curva de Bezier quadrática:2 ( ( 1 - t ) ( B - A ) + t ( C - B ) ) .Adicione ao Beziermétodo para calculá-lo.

  public static Vector3 GetDerivative ( Vector3 a, Vector3 b, Vector3 c, float t ) { return 2f * ((1f - t) * (b - a) + t * (c - b)); } 

O vetor derivado está localizado em uma linha reta com a direção do movimento. Podemos usar o método Quaternion.LookRotationpara convertê-lo em um turno de esquadrão. Vamos realizá-lo a cada passo HexUnit.TravelPath.

  transform.localPosition = Bezier.GetPoint(a, b, c, t); Vector3 d = Bezier.GetDerivative(a, b, c, t); transform.localRotation = Quaternion.LookRotation(d); yield return null; … transform.localPosition = Bezier.GetPoint(a, b, c, t); Vector3 d = Bezier.GetDerivative(a, b, c, t); transform.localRotation = Quaternion.LookRotation(d); yield return null; 

Não há erro no início do caminho?
, . A e B , . , t=0 , , Quaternion.LookRotation . , , t=0 . . , t>0 .
, t<1 .

Em contraste com a posição do desapego, a não idealidade de sua orientação no final do caminho não é importante. no entanto, precisamos garantir que sua orientação corresponda à rotação final. Para fazer isso, após a conclusão, equiparamos sua orientação à sua rotação em Y.

  transform.localPosition = location.Position; orientation = transform.localRotation.eulerAngles.y; 

Agora, as unidades estão olhando exatamente na direção do movimento, horizontal e verticalmente. Isso significa que eles se inclinam para frente e para trás, descendo das encostas e subindo nelas. Para garantir que eles sempre permaneçam retos, forçamos o componente Y do vetor de direção a zero antes de usá-lo para determinar a rotação da unidade.

  Vector3 d = Bezier.GetDerivative(a, b, c, t); dy = 0f; transform.localRotation = Quaternion.LookRotation(d); … Vector3 d = Bezier.GetDerivative(a, b, c, t); dy = 0f; transform.localRotation = Quaternion.LookRotation(d); 


Olhando para a frente enquanto se move

Nós olhamos para o ponto


Ao longo do caminho, as unidades estão olhando para frente, mas antes de começar a se mover, podem olhar na outra direção. Nesse caso, eles instantaneamente mudam de orientação. Será melhor se eles virarem na direção do caminho antes do início do movimento.

Olhar na direção certa pode ser útil em outras situações, então vamos criar um método LookAtque force o esquadrão a mudar de orientação para observar um determinado ponto. A rotação necessária pode ser definida usando o método Transform.LookAt, primeiro colocando o ponto na mesma posição vertical que o destacamento. Depois disso, podemos recuperar a orientação do esquadrão.

  void LookAt (Vector3 point) { point.y = transform.localPosition.y; transform.LookAt(point); orientation = transform.localRotation.eulerAngles.y; } 

Para que o desapego realmente gire, transformaremos o método em outro corutin que o girará a uma velocidade constante. A velocidade de rotação também pode ser ajustada, mas usaremos a constante novamente. A rotação deve ser rápida, cerca de 180 ° por segundo.

  const float rotationSpeed = 180f; … IEnumerator LookAt (Vector3 point) { … } 

Não é necessário mexer na aceleração do turno, porque é imperceptível. Basta que interpolemos entre as duas orientações. Infelizmente, isso não é tão simples como no caso de dois números, porque os ângulos são circulares. Por exemplo, uma transição de 350 ° para 10 ° deve resultar em uma rotação de 20 ° no sentido horário, mas uma interpolação simples forçará uma rotação de 340 ° no sentido anti-horário.

A maneira mais fácil de criar uma rotação correta é interpolar entre dois quaternions usando interpolação esférica. Isso levará ao turno mais curto. Para fazer isso, obtemos os quaternions do começo e do fim e, em seguida, fazemos uma transição entre eles usando Quaternion.Slerp.

  IEnumerator LookAt (Vector3 point) { point.y = transform.localPosition.y; Quaternion fromRotation = transform.localRotation; Quaternion toRotation = Quaternion.LookRotation(point - transform.localPosition); for (float t = Time.deltaTime; t < 1f; t += Time.deltaTime) { transform.localRotation = Quaternion.Slerp(fromRotation, toRotation, t); yield return null; } transform.LookAt(point); orientation = transform.localRotation.eulerAngles.y; } 

Isso funcionará, mas a interpolação sempre varia de 0 a 1, independentemente do ângulo de rotação. Para garantir velocidade angular uniforme, precisamos diminuir a interpolação à medida que o ângulo de rotação aumenta.

  Quaternion fromRotation = transform.localRotation; Quaternion toRotation = Quaternion.LookRotation(point - transform.localPosition); float angle = Quaternion.Angle(fromRotation, toRotation); float speed = rotationSpeed / angle; for ( float t = Time.deltaTime * speed; t < 1f; t += Time.deltaTime * speed ) { transform.localRotation = Quaternion.Slerp(fromRotation, toRotation, t); yield return null; } 

Conhecendo o ângulo, podemos pular completamente a curva se ela for zero.

  float angle = Quaternion.Angle(fromRotation, toRotation); if (angle > 0f) { float speed = rotationSpeed / angle; for ( … ) { … } } 

Agora podemos adicionar a rotação da unidade TravelPathsimplesmente executando o rendimento antes de mover a LookAtposição da segunda célula. O Unity iniciará automaticamente a rotina LookAte TravelPathaguardará sua conclusão.

  IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position); float t = Time.deltaTime * travelSpeed; … } 

Se você verificar o código, o esquadrão se teletransporta para a célula final, vira para lá e depois se teletransporta de volta para o início do caminho e começa a se mover a partir daí. Isso acontece porque atribuímos um valor a uma propriedade Locationantes do início da corotina TravelPath. Para se livrar do teletransporte, podemos, no início, TravelPathretornar a posição do desapego à célula inicial.

  Vector3 a, b, c = pathToTravel[0].Position; transform.localPosition = c; yield return LookAt(pathToTravel[1].Position); 


Gire antes de mover

Varrer


Tendo recebido o movimento de que precisamos, podemos nos livrar do método OnDrawGizmos. Exclua ou comente se precisarmos ver caminhos no futuro.

 // void OnDrawGizmos () { // … // } 

Como não precisamos mais lembrar para onde estávamos nos movendo, no final, TravelPathvocê pode liberar a lista de células.

  IEnumerator TravelPath () { … ListPool<HexCell>.Add(pathToTravel); pathToTravel = null; } 

E as animações de esquadrão real?
, . 3D- . . , . Mecanim, TravelPath .

unitypackage

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


All Articles