Criando Tower Defense na Unity, Parte 1

O campo


  • Criando um campo de bloco.
  • Caminhos de pesquisa usando a pesquisa pela primeira vez.
  • Implemente suporte para ladrilhos vazios e finais, além de ladrilhos.
  • Editando conteúdo no modo de jogo.
  • Exibição opcional de campos e caminhos da grade.

Esta é a primeira parte de uma série de tutoriais sobre como criar um jogo simples de defesa de torre . Nesta parte, consideraremos a criação de um campo de jogo, a localização de um caminho e a colocação de ladrilhos e paredes finais.

O tutorial foi criado no Unity 2018.3.0f2.


Um campo pronto para uso em um jogo de blocos de gênero de defesa de torre.

Jogo de Tower Defense


Defesa de torre é um gênero em que o objetivo do jogador é destruir multidões de inimigos até que cheguem ao ponto final. O jogador cumpre seu objetivo construindo torres que atacam os inimigos. Este gênero tem muitas variações. Vamos criar um jogo com um campo de peças. Os inimigos se moverão pelo campo em direção ao seu ponto final, e o jogador criará obstáculos para eles.

Assumirei que você já estudou uma série de tutoriais sobre gerenciamento de objetos .

O campo


O campo de jogo é a parte mais importante do jogo, por isso vamos criá-lo primeiro. Este será um objeto de jogo com seu próprio componente GameBoard , que pode ser inicializado definindo o tamanho em duas dimensões, para as quais podemos usar o valor de Vector2Int . O campo deve funcionar com qualquer tamanho, mas escolheremos o tamanho em outro lugar, portanto, criaremos um método Initialize comum para isso.

Além disso, visualizamos o campo com um quadrilátero, que indicará a terra. Não transformaremos o objeto de campo em um quadrilátero, mas adicionaremos um objeto quad filho a ele. Após a inicialização, tornaremos a escala XY da Terra igual ao tamanho do campo. Ou seja, cada bloco terá o tamanho de uma unidade de medida quadrada para o mecanismo.

 using UnityEngine; public class GameBoard : MonoBehaviour { [SerializeField] Transform ground = default; Vector2Int size; public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); } } 

Por que definir explicitamente o valor padrão?
A idéia é que tudo personalizável através do editor Unity seja acessível através de campos ocultos serializados. É necessário que esses campos possam ser alterados apenas no inspetor. Infelizmente, o editor do Unity exibirá constantemente um aviso do compilador de que o valor nunca é atribuído. Podemos suprimir esse aviso definindo explicitamente o valor padrão para o campo. Você também pode atribuir null , mas eu fiz isso para mostrar explicitamente que simplesmente usamos o valor padrão, que não é uma referência verdadeira ao solo, portanto usamos o default .

Crie um objeto de campo em uma nova cena e adicione um quad filho com um material que se pareça com terra. Como estamos criando um jogo simples de protótipo, um material verde uniforme será suficiente. Gire o quadrilátero 90 ° ao longo do eixo X para que fique no plano XZ.




Campo de jogo.

Por que não posicionar o jogo no avião XY?
Embora o jogo ocorra no espaço 2D, nós o renderizamos em 3D, com inimigos em 3D e uma câmera que pode ser movida em relação a um determinado ponto. O plano XZ é mais conveniente para isso e corresponde à orientação padrão do skybox usada para iluminação ambiente.

O jogo


Em seguida, crie um componente do Game que será responsável por todo o jogo. Nesta fase, isso significa que está inicializando o campo. Nós apenas tornamos o tamanho personalizável através do inspetor e forçamos o componente a inicializar o campo quando ele acordar. Vamos usar o tamanho padrão de 11 × 11.

 using UnityEngine; public class Game : MonoBehaviour { [SerializeField] Vector2Int boardSize = new Vector2Int(11, 11); [SerializeField] GameBoard board = default; void Awake () { board.Initialize(boardSize); } } 

O tamanho dos campos só pode ser positivo e faz pouco sentido criar um campo com um único bloco. Então, vamos limitar o mínimo a 2 × 2. Isso pode ser feito adicionando o método OnValidate , limitando à força os valores mínimos.

  void OnValidate () { if (boardSize.x < 2) { boardSize.x = 2; } if (boardSize.y < 2) { boardSize.y = 2; } } 

Quando o Onvalidate é chamado?
Se existir, o editor do Unity chama os componentes depois de alterá-los. Inclusão ao adicioná-los ao objeto do jogo, após carregar a cena, recompilar, alterar o editor, cancelar / tentar novamente e redefinir o componente.

OnValidate é o único local no código em que você pode atribuir valores aos campos de configuração do componente.


Objeto de jogo.

Agora, quando você iniciar o modo de jogo, receberemos um campo com o tamanho correto. Durante o jogo, posicione a câmera para que todo o quadro fique visível, copie seu componente de transformação, saia do modo de reprodução e cole os valores do componente. No caso de um campo 11 × 11 na origem, para obter uma visão conveniente de cima, você pode posicionar a câmera na posição (0.10.0) e girá-la 90 ° ao longo do eixo X. Vamos deixar a câmera nessa posição fixa, mas é possível mude no futuro.


Câmera sobre o campo.

Como copiar e colar valores de componentes?
Através do menu suspenso que aparece quando você clica no botão com a engrenagem no canto superior direito do componente.

Ladrilho pré-fabricado


O campo consiste em ladrilhos quadrados. Os inimigos serão capazes de passar de lado a lado, cruzando as bordas, mas não na diagonal. O movimento sempre ocorrerá no ponto final mais próximo. Vamos denotar graficamente a direção do movimento ao longo do bloco com uma seta. Você pode baixar a textura da seta aqui .


Seta em um fundo preto.

Coloque a textura da seta no seu projeto e ative a opção Alfa como transparência . Em seguida, crie um material para a seta, que pode ser o material padrão para o qual o modo de recorte está selecionado, e selecione a seta como textura principal.


Material de flecha.

Por que usar o modo de renderização de recorte?
Ele permite ocultar a seta usando o pipeline de renderização padrão do Unity.

Para indicar cada peça do jogo, usaremos o objeto do jogo. Cada um deles terá seu próprio quad com material de flecha, assim como o campo tem um quad com terra. Também adicionaremos blocos ao componente GameTile com um link para sua seta.

 using UnityEngine; public class GameTile : MonoBehaviour { [SerializeField] Transform arrow = default; } 

Crie um objeto de bloco e transforme-o em uma pré-fabricada. Os ladrilhos ficarão alinhados com o chão; portanto, levante a seta um pouco para evitar problemas de profundidade ao renderizar. Diminua também um pouco o zoom, para que haja pouco espaço entre as setas adjacentes. Um deslocamento Y de 0,001 e uma escala de 0,8 igual para todos os eixos serão suficientes.




Telha pré-fabricada.

Onde está a hierarquia de blocos pré-fabricados?
Você pode abrir o modo de edição pré-fabricada clicando duas vezes no ativo pré-fabricado ou selecionando a pré-fabricada e clicando no botão Abrir pré-fabricada no inspetor. Você pode sair do modo de edição pré-fabricada clicando no botão com uma seta no canto superior esquerdo do cabeçalho da hierarquia.

Note que as peças em si não precisam ser objetos de jogo. Eles são necessários apenas para rastrear o estado do campo. Poderíamos usar a mesma abordagem do comportamento na série de tutoriais sobre gerenciamento de objetos . Mas nos estágios iniciais de jogos simples ou protótipos de objetos de jogo, estamos muito felizes. Isso pode ser alterado no futuro.

Nós temos azulejos


Para criar blocos, o GameBoard deve ter um link para a pré-montagem de blocos.

  [SerializeField] GameTile tilePrefab = default; 


Link para o bloco pré-fabricado.

Ele pode criar suas instâncias usando um loop duplo sobre duas dimensões de grade. Embora o tamanho seja expresso em X e Y, organizaremos os blocos no plano XZ, bem como o próprio campo. Como o campo está centralizado em relação à origem, precisamos subtrair o tamanho correspondente menos um dividido por dois dos componentes da posição do bloco. Observe que deve ser uma divisão de ponto flutuante, caso contrário não funcionará para tamanhos pares.

  public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); Vector2 offset = new Vector2( (size.x - 1) * 0.5f, (size.y - 1) * 0.5f ); for (int y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++) { GameTile tile = Instantiate(tilePrefab); tile.transform.SetParent(transform, false); tile.transform.localPosition = new Vector3( x - offset.x, 0f, y - offset.y ); } } } 


Instâncias criadas de blocos.

Mais tarde, precisaremos de acesso a esses blocos, para rastrearmos em um array. Não precisamos de uma lista, porque após a inicialização, o tamanho do campo não será alterado.

  GameTile[] tiles; public void Initialize (Vector2Int size) { … tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { GameTile tile = tiles[i] = Instantiate(tilePrefab); … } } } 

Como essa tarefa funciona?
Esta é uma tarefa vinculada. Nesse caso, isso significa que estamos atribuindo um link à instância do bloco para o elemento da matriz e a variável local. Essas operações executam o mesmo que o código mostrado abaixo.

 GameTile t = Instantiate(tilePrefab); tiles[i] = t; GameTile tile = t; 

Procure uma maneira


Nesse estágio, cada bloco possui uma seta, mas todos apontam na direção positiva do eixo Z, que interpretaremos como norte. O próximo passo é determinar a direção correta para o bloco. Fazemos isso encontrando o caminho que os inimigos devem seguir até o ponto final.

Tile Neighbors


Os caminhos vão de telha em telha, no norte, leste, sul ou oeste. Para simplificar a pesquisa, faça com que o GameTile rastreie os links para seus quatro vizinhos.

  GameTile north, east, south, west; 

As relações entre vizinhos são simétricas. Se o ladrilho é o vizinho oriental do segundo ladrilho, o segundo é o vizinho ocidental do primeiro. Adicione um método estático geral ao GameTile para definir esse relacionamento entre dois blocos.

  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { west.east = east; east.west = west; } 

Por que usar um método estático?
Podemos transformá-lo em um método de instância com um único parâmetro e, nesse caso, chamaremos de eastTile.MakeEastWestNeighbors(westTile) ou algo assim. Mas nos casos em que não está claro em quais blocos o método deve ser chamado, é melhor usar métodos estáticos. Exemplos são os métodos Distance e Dot da classe Vector3 .

Uma vez conectado, nunca deve mudar. Se isso acontecer, cometemos um erro no código. Você pode verificar isso comparando os dois links antes de atribuir valores a null e exibindo um erro se estiver incorreto. Você pode usar o método Debug.Assert para Debug.Assert .

  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { Debug.Assert( west.east == null && east.west == null, "Redefined neighbors!" ); west.east = east; east.west = west; } 

O que o Debug.Assert faz?
Se o primeiro argumento for false , ele exibirá um erro de condição, usando o segundo argumento, se especificado. Essa chamada é incluída apenas nas versões de teste, mas não nas versões. Portanto, é uma boa maneira de adicionar verificações durante o processo de desenvolvimento que não afetarão a versão final.

Adicione um método semelhante para criar relações entre os vizinhos do norte e do sul.

  public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) { Debug.Assert( south.north == null && north.south == null, "Redefined neighbors!" ); south.north = north; north.south = south; } 

Podemos estabelecer esse relacionamento ao criar blocos no GameBoard.Initialize . Se a coordenada X for maior que zero, podemos criar uma relação leste-oeste entre os blocos atuais e os anteriores. Se a coordenada Y for maior que zero, podemos criar uma relação norte-sul entre o bloco atual e o bloco da linha anterior.

  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … if (x > 0) { GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]); } if (y > 0) { GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]); } } } 

Observe que os ladrilhos nas bordas do campo não têm quatro vizinhos. Uma ou duas referências de vizinhos permanecerão null .

Distância e direção


Não forçaremos todos os inimigos a procurar constantemente o caminho. Isso precisa ser feito apenas uma vez por bloco. Em seguida, os inimigos poderão solicitar do bloco em que estão localizados para onde seguir em frente. Armazenaremos essas informações no GameTile adicionando um link para o próximo bloco de caminho. Além disso, também salvaremos a distância até o ponto final, expressa como o número de peças que devem ser visitadas antes que o inimigo atinja o ponto final. Para os inimigos, essas informações são inúteis, mas as usaremos para encontrar os caminhos mais curtos.

  GameTile north, east, south, west, nextOnPath; int distance; 

Cada vez que decidimos que precisamos procurar caminhos, precisamos inicializar os dados do caminho. Até que o caminho seja encontrado, não há próximo bloco e a distância pode ser considerada infinita. Podemos imaginar isso como o valor inteiro máximo possível de int.MaxValue . Adicione um método ClearPath genérico para redefinir o GameTile para esse estado.

  public void ClearPath () { distance = int.MaxValue; nextOnPath = null; } 

Os caminhos só podem ser pesquisados ​​se tivermos um ponto final. Isso significa que o bloco deve se tornar o ponto final. Esse bloco tem uma distância de zero e não possui o último bloco, porque o caminho termina nele. Adicione um método genérico que transforma um bloco em um terminal.

  public void BecomeDestination () { distance = 0; nextOnPath = null; } 

Por fim, todos os blocos devem se transformar em um caminho, para que sua distância não seja mais igual a int.MaxValue . Adicione uma propriedade getter conveniente para verificar se o bloco atualmente possui um caminho.

  public bool HasPath => distance != int.MaxValue; 

Como esta propriedade funciona?
Esta é uma entrada abreviada para uma propriedade getter que contém apenas uma expressão. Faz o mesmo que o código mostrado abaixo.

  public bool HasPath { get { return distance != int.MaxValue; } } 

O operador de seta => também pode ser usado individualmente para obter e definir propriedades, para os corpos de métodos, construtores e em alguns outros lugares.

Nós crescemos um caminho


Se tivermos um ladrilho com um caminho, podemos deixá-lo crescer em direção a um de seus vizinhos. Inicialmente, o único bloco com o caminho é o ponto final, então começamos a partir da distância zero e aumentamos a partir daqui, movendo-nos na direção oposta ao movimento dos inimigos. Ou seja, todos os vizinhos imediatos do ponto final terão uma distância de 1, e todos os vizinhos desses blocos terão uma distância de 2, e assim por diante.

Adicione um método oculto GameTile para aumentar o caminho para um de seus vizinhos, especificado por meio do parâmetro A distância para o vizinho é uma a mais que o bloco atual e o caminho do vizinho indica o bloco atual. Este método deve ser chamado apenas para blocos que já possuem um caminho, então vamos verificar isso com assert.

  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); neighbor.distance = distance + 1; neighbor.nextOnPath = this; } 

A ideia é chamar esse método uma vez para cada um dos quatro vizinhos do bloco. Como alguns desses links serão null , verificaremos isso e interromperemos a execução, se houver. Além disso, se um vizinho já tiver um caminho, não devemos fazer nada e também parar de fazê-lo.

  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); if (neighbor == null || neighbor.HasPath) { return; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; } 

A maneira como o GameTile rastreia seus vizinhos é desconhecida para o restante do código. Portanto, GrowPathTo está oculto. Adicionaremos métodos gerais que dizem ao bloco para aumentar seu caminho em uma determinada direção, chamando indiretamente de GrowPathTo . Mas o código que pesquisa em todo o campo deve acompanhar quais blocos foram visitados. Portanto, faremos com que ele retorne um vizinho ou um valor null se a execução for encerrada.

  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor; } 

Agora adicione métodos para o crescimento de caminhos em direções específicas.

  public GameTile GrowPathNorth () => GrowPathTo(north); public GameTile GrowPathEast () => GrowPathTo(east); public GameTile GrowPathSouth () => GrowPathTo(south); public GameTile GrowPathWest () => GrowPathTo(west); 

Pesquisa ampla


GameBoard deve GameBoard que todos os blocos contenham os dados do caminho correto. Fazemos isso realizando uma pesquisa abrangente. Vamos começar com o bloco de extremidade e, em seguida, aumentar o caminho para seus vizinhos, depois para os vizinhos desses blocos e assim por diante. A cada passo, a distância aumenta em um e os caminhos nunca crescem na direção dos ladrilhos que já possuem caminhos. Isso garante que todos os blocos, como resultado, apontem ao longo do caminho mais curto para o terminal.

Que tal encontrar um caminho usando A *?
O algoritmo A * é o desenvolvimento evolutivo da busca pela primeira vez. É útil quando estamos procurando o único caminho mais curto. Como precisamos de todos os caminhos mais curtos, o A * não oferece vantagens. Para exemplos de pesquisa de amplitude inicial e A * em uma grade de hexágonos com animação, consulte a série de tutoriais sobre mapas de hexágonos .

Para realizar a pesquisa, precisamos rastrear os blocos que adicionamos ao caminho, mas a partir dos quais ainda não crescemos o caminho. Essa coleção de blocos é frequentemente chamada de fronteira de pesquisa. É importante que os blocos sejam processados ​​na mesma ordem em que são adicionados à borda, então vamos usar a Queue . Mais tarde, teremos que realizar a pesquisa várias vezes, então vamos configurá-lo como o campo do GameBoard .

 using UnityEngine; using System.Collections.Generic; public class GameBoard : MonoBehaviour { … Queue<GameTile> searchFrontier = new Queue<GameTile>(); … } 

Para que o estado do campo de jogo seja sempre verdadeiro, precisamos encontrar os caminhos no final de Initialize , mas coloque o código em um método FindPaths separado. Primeiro de tudo, você precisa limpar o caminho de todos os blocos, depois fazer um bloco o ponto final e adicioná-lo à borda. Vamos primeiro selecionar o primeiro bloco. Como os tiles são uma matriz, podemos usar o foreach sem medo de poluição da memória. Se passarmos posteriormente de uma matriz para uma lista, também precisaremos substituir os loops foreach for loops for .

  public void Initialize (Vector2Int size) { … FindPaths(); } void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); } 

Em seguida, precisamos pegar um bloco da borda e criar um caminho para todos os seus vizinhos, adicionando-os todos à borda. Primeiro vamos nos mover para o norte, depois para o leste, sul e finalmente para o oeste.

  public void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

Repetimos esse estágio, enquanto há azulejos na borda.

  while (searchFrontier.Count > 0) { GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

Crescer um caminho nem sempre nos leva a um novo bloco. Antes de adicionar à fila, precisamos verificar o valor como null , mas podemos adiar a verificação por null até depois da saída da fila.

  GameTile tile = searchFrontier.Dequeue(); if (tile != null) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); } 

Exibir os caminhos


Agora temos um campo que contém os caminhos corretos, mas até agora não vemos isso. Você precisa configurar as setas para que elas apontem ao longo do caminho através de seus blocos. Isso pode ser feito girando-os. Como esses turnos são sempre os mesmos, adicionamos ao GameTile um campo estático de Quaternion para cada uma das direções.

  static Quaternion northRotation = Quaternion.Euler(90f, 0f, 0f), eastRotation = Quaternion.Euler(90f, 90f, 0f), southRotation = Quaternion.Euler(90f, 180f, 0f), westRotation = Quaternion.Euler(90f, 270f, 0f); 

Adicione também o método ShowPath geral. Se a distância for zero, o bloco é o ponto final e não há nada para o qual apontar; portanto, desative sua seta. Caso contrário, ative a seta e defina sua rotação. A direção desejada pode ser determinada comparando nextOnPath com seus vizinhos.

  public void ShowPath () { if (distance == 0) { arrow.gameObject.SetActive(false); return; } arrow.gameObject.SetActive(true); arrow.localRotation = nextOnPath == north ? northRotation : nextOnPath == east ? eastRotation : nextOnPath == south ? southRotation : westRotation; } 

Chame esse método para todos os blocos no final GameBoard.FindPaths.

  public void FindPaths () { … foreach (GameTile tile in tiles) { tile.ShowPath(); } } 


Maneiras encontradas.

Por que não transformamos a seta diretamente no GrowPathTo?
Separar a lógica e a visualização da pesquisa. Mais tarde, desativaremos a visualização. Se as setas não aparecerem, não precisamos girá-las sempre que chamarmos FindPaths.

Alterar prioridade de pesquisa


Acontece que, quando o ponto final é o canto sudoeste, todos os caminhos vão exatamente para o oeste até chegarem à borda do campo, após o que eles se voltam para o sul. Tudo é verdade aqui, porque realmente não existem caminhos mais curtos para o ponto final, porque os movimentos diagonais são impossíveis. No entanto, existem muitos outros caminhos mais curtos que podem parecer mais bonitos.

Para entender melhor por que esses caminhos são encontrados, mova o ponto final para o centro do mapa. Com um tamanho de campo ímpar, é apenas um bloco no meio da matriz.

  tiles[tiles.Length / 2].BecomeDestination(); searchFrontier.Enqueue(tiles[tiles.Length / 2]); 


Ponto final no centro.

O resultado parece lógico se você se lembrar de como a pesquisa funciona. Como adicionamos vizinhos na ordem nordeste-sudoeste-oeste, o norte tem a maior prioridade. Como estamos fazendo a pesquisa na ordem inversa, isso significa que a última direção que seguimos é para o sul. É por isso que apenas algumas flechas apontam para o sul e muitas apontam para o leste.

Você pode alterar o resultado definindo as prioridades das direções. Vamos trocar leste e sul. Portanto, temos que obter a simetria norte-sul e leste-oeste.

  searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()) 


A ordem de pesquisa é norte-sul-leste-oeste.

Parece mais bonito, mas é melhor que os caminhos mudem de direção, aproximando-se do movimento diagonal onde parecerá natural. Podemos fazer isso revertendo as prioridades de pesquisa de blocos vizinhos em um padrão quadriculado.

Em vez de descobrir que tipo de bloco estamos processando durante a pesquisa, adicionamos à GameTilepropriedade geral que indica se o bloco atual é uma alternativa.

  public bool IsAlternative { get; set; } 

Definiremos essa propriedade em GameBoard.Initialize. Primeiro, marque os ladrilhos como alternativa se a coordenada X for par.

  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.IsAlternative = (x & 1) == 0; } } 

O que a operação (x & 1) == 0 faz?
— (AND). . 1, 1. 10101010 00001111 00001010.

. 0 1. 1, 2, 3, 4 1, 10, 11, 100. , .

AND , , . , .

Em segundo lugar, alteramos o sinal do resultado se a coordenada Y for par. Então, criaremos um padrão de xadrez.

  tile.IsAlternative = (x & 1) == 0; if ((y & 1) == 0) { tile.IsAlternative = !tile.IsAlternative; } 

Como FindPathspodemos manter a mesma ordem que a busca de telha alternativa, mas para torná-lo de volta para todas as outras peças. Isso forçará o caminho para o movimento diagonal e criará ziguezagues.

  if (tile != null) { if (tile.IsAlternative) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()); } else { searchFrontier.Enqueue(tile.GrowPathWest()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathNorth()); } } 


Ordem de pesquisa variável.

Trocando peças


Neste ponto, todos os blocos estão vazios. Um bloco é usado como um ponto final, mas, além da ausência de uma seta visível, parece igual a todos os outros. Adicionaremos a capacidade de alterar peças colocando objetos sobre elas.

Conteúdo em bloco


Os próprios objetos de bloco são simplesmente uma maneira de rastrear informações de bloco. Nós não modificamos esses objetos diretamente. Em vez disso, adicione conteúdo separado e coloque-o no campo. Por enquanto, podemos distinguir entre blocos vazios e blocos de terminais. Para indicar esses casos, crie uma enumeração GameTileContentType.

 public enum GameTileContentType { Empty, Destination } 

Em seguida, crie um tipo de componente GameTileContentque permita definir o tipo de seu conteúdo por meio do inspetor, e o acesso a ele será feito por meio de uma propriedade getter comum.

 using UnityEngine; public class GameTileContent : MonoBehaviour { [SerializeField] GameTileContentType type = default; public GameTileContentType Type => type; } 

Em seguida, criaremos prefabs para dois tipos de conteúdo, cada um GameTileContentcom um componente com o tipo especificado correspondente. Vamos usar um cubo achatado azul para designar blocos de ponto final. Como é quase plana, ele não precisa de um colisor. Para pré-fabricar conteúdo vazio, use um objeto de jogo vazio.

destino

vazio

Prefabs do terminal e conteúdo vazio.

Daremos o objeto de conteúdo aos blocos vazios, porque todos os blocos terão sempre o conteúdo, o que significa que não precisaremos verificar os links para o conteúdo quanto à igualdade null.

Fábrica de Conteúdo


Para tornar o conteúdo editável, também criaremos uma fábrica para isso, usando a mesma abordagem do tutorial Gerenciamento de Objetos . Isso significa que você GameTileContentdeve acompanhar sua fábrica original, que deve ser configurada apenas uma vez, e enviar-se de volta à fábrica no método Recycle.

  GameTileContentFactory originFactory; … public GameTileContentFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } 

Isso pressupõe a existência GameTileContentFactory; portanto, criaremos um tipo de objeto programável para isso com o método necessário Recycle. Nesta fase, não vamos nos preocupar com a criação de uma fábrica totalmente funcional que utilize o conteúdo, por isso vamos simplesmente destruir o conteúdo. Posteriormente, será possível adicionar a reutilização de objetos à fábrica sem alterar o restante do código.

 using UnityEngine; using UnityEngine.SceneManagement; [CreateAssetMenu] public class GameTileContentFactory : ScriptableObject { public void Reclaim (GameTileContent content) { Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(content.gameObject); } } 

Adicione um método oculto Getà fábrica com uma pré-fabricada como parâmetro. Aqui, novamente pulamos a reutilização de objetos. Ele cria uma instância do objeto, define sua fábrica original, move-a para a cena da fábrica e a devolve.

  GameTileContent Get (GameTileContent prefab) { GameTileContent instance = Instantiate(prefab); instance.OriginFactory = this; MoveToFactoryScene(instance.gameObject); return instance; } 

A instância foi movida para a cena de conteúdo de fábrica, que pode ser criada conforme necessário. Se estivermos no editor, antes de criar uma cena, precisamos verificar se ela existe, caso a percamos de vista durante uma reinicialização a quente.

  Scene contentScene; … void MoveToFactoryScene (GameObject o) { if (!contentScene.isLoaded) { if (Application.isEditor) { contentScene = SceneManager.GetSceneByName(name); if (!contentScene.isLoaded) { contentScene = SceneManager.CreateScene(name); } } else { contentScene = SceneManager.CreateScene(name); } } SceneManager.MoveGameObjectToScene(o, contentScene); } 

Temos apenas dois tipos de conteúdo, portanto, basta adicionar dois campos de configuração pré-fabricados para eles.

  [SerializeField] GameTileContent destinationPrefab = default; [SerializeField] GameTileContent emptyPrefab = default; 

A última coisa que precisa ser feita para que a fábrica funcione é criar um método geral Getcom um parâmetro GameTileContentTypeque receba uma instância da pré-fabricada correspondente.

  public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 

É obrigatório adicionar uma instância separada de conteúdo vazio a cada bloco?
, . . , - , , , , . , . , , .

Vamos criar um ativo de fábrica e configurar seus links para prefabs.


Fábrica de Conteúdo

E depois passe o Gamelink para a fábrica.

  [SerializeField] GameTileContentFactory tileContentFactory = default; 


Jogo com uma fábrica.

Tocando em um bloco


Para alterar o campo, precisamos poder selecionar um bloco. Tornaremos isso possível no modo de jogo. Emitiremos um feixe para a cena no local em que o jogador clicou na janela do jogo. Se o feixe cruzar com o ladrilho, o jogador tocou nele, ou seja, ele deve ser alterado. Gamelidará com a entrada do jogador, mas será responsável por determinar em qual bloco o jogador tocou GameBoard.

Nem todos os raios se cruzam com o ladrilho, portanto, às vezes, não receberemos nada. Portanto, adicionamos ao GameBoardmétodo GetTile, que sempre sempre retorna inicialmente null(isso significa que o bloco não foi encontrado).

  public GameTile GetTile (Ray ray) { return null; } 

Para determinar se um raio cruzou um bloco, precisamos chamar Physics.Raycastespecificando o raio como argumento. Retorna informações sobre se houve uma interseção. Nesse caso, podemos devolver o bloco, embora ainda não saibamos qual, por enquanto, o devolveremos null.

  public GameTile TryGetTile (Ray ray) { if (Physics.Raycast(ray) { return null; } return null; } 

Para descobrir se houve um cruzamento com um bloco, precisamos de mais informações sobre o cruzamento. Physics.Raycastpode fornecer essas informações usando o segundo parâmetro RaycastHit. Este é o parâmetro de saída, indicado pela palavra outà sua frente. Isso significa que uma chamada de método pode atribuir um valor à variável que passamos para ela.

  RaycastHit hit; if (Physics.Raycast(ray, out hit) { return null; } 

Podemos incorporar a declaração das variáveis ​​usadas para os parâmetros de saída, então vamos fazê-lo.

  if (Physics.Raycast(ray, out RaycastHit hit) { return null; } 

Não nos importamos com qual colisor ocorreu a interseção, apenas usamos a posição de interseção XZ para determinar o bloco. Obtemos as coordenadas do bloco adicionando metade do tamanho do campo às coordenadas do ponto de interseção e convertendo os resultados em valores inteiros. O índice final do bloco, como resultado, será sua coordenada X mais a coordenada Y multiplicada pela largura do campo.

  if (Physics.Raycast(ray, out RaycastHit hit)) { int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); return tiles[x + y * size.x]; } 

Mas isso só é possível quando as coordenadas do bloco estão dentro do campo, portanto, verificaremos isso. Se não for esse o caso, o bloco não será retornado.

  int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); if (x >= 0 && x < size.x && y >= 0 && y < size.y) { return tiles[x + y * size.x]; } 

Alteração de conteúdo


Para ser capaz de modificar o conteúdo de uma telha, adicionar a GameTilepropriedade comum Content. Seu getter simplesmente retorna o conteúdo, e o setter descarta o conteúdo anterior, se houver, e coloca o novo conteúdo.

  GameTileContent content; public GameTileContent Content { get => content; set { if (content != null) { content.Recycle(); } content = value; content.transform.localPosition = transform.localPosition; } } 

Este é o único lugar em que você precisa verificar o conteúdo null, porque inicialmente não temos conteúdo. Para garantir, executamos assert para que o setter não seja chamado com null.

  set { Debug.Assert(value != null, "Null assigned to content!"); … } 

E, finalmente, precisamos de uma entrada do jogador. A conversão de um clique do mouse em um raio pode ser feita chamando ScreenPointToRay-o Input.mousePositioncomo argumento. A chamada deve ser feita para a câmera principal, que pode ser acessada através Camera.main. Adicione a propriedade c para isso Game.

  Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition); 

Em seguida, adicionamos um método Updateque verifica se o botão principal do mouse foi pressionado durante a atualização. Para fazer isso, chame Input.GetMouseButtonDowncom zero como argumento. Se a tecla foi pressionada, processamos o toque do jogador, ou seja, pegamos o bloco do campo e definimos o ponto final como seu conteúdo, retirando-o da fábrica.

  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } } void HandleTouch () { GameTile tile = GetTile(TouchRay); if (tile != null) { tile.Content = tileContentFactory.Get(GameTileContentType.Destination); } } 

Agora podemos transformar qualquer bloco em um ponto final pressionando o cursor.


Vários pontos finais.

Tornando o campo correto


Embora possamos transformar blocos em pontos finais, isso não afeta os caminhos até o momento. Além disso, ainda não definimos conteúdo vazio para blocos. Manter a correção e a integridade do campo é uma tarefa GameBoard, então vamos dar a ele a responsabilidade de definir o conteúdo do bloco. Para implementar isso, forneceremos um link para a fábrica de conteúdo por meio de seu método Intializee usaremos para fornecer a todos os blocos uma instância de conteúdo vazio.

  GameTileContentFactory contentFactory; public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { this.size = size; this.contentFactory = contentFactory; ground.localScale = new Vector3(size.x, size.y, 1f); tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.Content = contentFactory.Get(GameTileContentType.Empty); } } FindPaths(); } 

Agora eu Gametenho que transferir minha fábrica para o campo.

  void Awake () { board.Initialize(boardSize, tileContentFactory); } 

Por que não adicionar um campo de configuração de fábrica ao GameBoard?
, , . , .

Como agora temos vários pontos de extremidade, alteramos GameBoard.FindPathspara que ele chame BecomeDestinationcada um e os adicione à borda. E isso é tudo o que é necessário para suportar vários pontos de extremidade. Todos os outros ladrilhos são limpos como de costume. Em seguida, excluímos o ponto final definido no centro.

  void FindPaths () { foreach (GameTile tile in tiles) { if (tile.Content.Type == GameTileContentType.Destination) { tile.BecomeDestination(); searchFrontier.Enqueue(tile); } else { tile.ClearPath(); } } //tiles[tiles.Length / 2].BecomeDestination(); //searchFrontier.Enqueue(tiles[tiles.Length / 2]); … } 

Mas se pudermos transformar blocos em pontos finais, poderemos executar a operação reversa, transformar pontos finais em blocos vazios. Mas então podemos obter um campo sem pontos finais. Nesse caso, FindPathsnão será capaz de executar sua tarefa. Isso acontece quando a borda está vazia após a inicialização do caminho para todas as células. Denotamos isso como um estado inválido do campo, retornando falsee concluindo a execução; caso contrário, retorne no final true.

  bool FindPaths () { foreach (GameTile tile in tiles) { … } if (searchFrontier.Count == 0) { return false; } … return true; } 

A maneira mais fácil de implementar o suporte à remoção de terminais, tornando-o uma operação de switch. Ao clicar nos blocos vazios, os transformaremos em pontos finais e, ao clicar nos pontos finais, os excluiremos. Mas agora ele está envolvido na alteração do conteúdo GameBoard, por isso, forneceremos um método geral ToggleDestination, cujo parâmetro é o bloco. Se o bloco for o ponto final, deixe-o vazio e ligue FindPaths. Caso contrário, nós o tornamos o ponto final e também o chamamos FindPaths.

  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

Adicionar um terminal nunca pode criar um estado de campo inválido e excluir um terminal. Portanto, verificaremos se a execução foi bem-sucedida FindPathsdepois de deixar o bloco vazio. Caso contrário, cancele a alteração, retornando o bloco ao ponto final e ligue novamente FindPathspara retornar ao estado correto anterior.

  if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

A validação pode ser mais eficiente?
, . , . , . FindPaths , .

Agora, no final Initialize, podemos chamar ToggleDestinationcom o bloco central como argumento, em vez de chamar explicitamente FindPaths. É a única vez que começamos com um estado de campo inválido, mas garantimos que terminamos com o estado correto.

  public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { … //FindPaths(); ToggleDestination(tiles[tiles.Length / 2]); } 

Finalmente, forçamos a Gamechamada em ToggleDestinationvez de definir o conteúdo do próprio bloco.

  void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { //tile.Content = //tileContentFactory.Get(GameTileContentType.Destination); board.ToggleDestination(tile); } } 


Vários pontos de extremidade com caminhos corretos.

Não devemos proibir o jogo de definir o conteúdo do bloco diretamente?
. . , Game . , .

As paredes


O objetivo da defesa da torre é impedir que os inimigos cheguem ao ponto final. Este objetivo é alcançado de duas maneiras. Primeiro, nós os matamos e, em segundo lugar, diminuí-los para que haja mais tempo para matá-los. No campo de ladrilhos, o tempo pode ser aumentado, aumentando a distância que os inimigos precisam percorrer. Isso pode ser alcançado colocando obstáculos no campo. Geralmente, são torres que também matam inimigos, mas neste tutorial nos limitaremos apenas a paredes.

Conteúdo


Paredes são outro tipo de conteúdo, então vamos adicionar GameTileContentTypeum elemento a elas.

 public enum GameTileContentType { Empty, Destination, Wall } 

Em seguida, crie a pré-fabricada da parede. Dessa vez, criaremos um objeto de jogo com o conteúdo do bloco e adicionaremos um cubo filho, que estará no topo do campo e preencherá o bloco inteiro. Faça meia unidade de altura e salve o colisor, porque as paredes podem se sobrepor visualmente a parte dos ladrilhos atrás dele. Portanto, quando um jogador toca uma parede, ele influencia o azulejo correspondente.

raiz

cubo

pré-fabricado

Parede pré-fabricada.

Adicione a pré-fabricada da parede à fábrica, tanto no código quanto no inspetor.

  [SerializeField] GameTileContent wallPrefab = default; … public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); case GameTileContentType.Wall: return Get(wallPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 


Fábrica com parede pré-fabricada.

Ligar e desligar paredes


Adicione ao GameBoardmétodo on / off das paredes, como fizemos no ponto final. Inicialmente, não verificaremos o estado incorreto do campo.

  public void ToggleWall (GameTile tile) { if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Wall); FindPaths(); } } 

Forneceremos suporte para alternar apenas entre ladrilhos vazios e ladrilhos, não permitindo que as paredes substituam diretamente os pontos de extremidade. Portanto, apenas criaremos uma parede quando o ladrilho estiver vazio. Além disso, as paredes devem bloquear a busca pelo caminho. Mas cada peça deve ter um caminho até o ponto final, caso contrário, os inimigos ficam presos. Para fazer isso, precisamos novamente usar a validação FindPathse descartar as alterações se elas criarem um estado de campo incorreto.

  else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Wall); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } } 

A ativação e desativação de paredes será usada com muito mais frequência do que a ativação e desativação de terminais, portanto, faremos a troca de paredes no Gametoque principal. Os pontos de extremidade podem ser alternados com um toque adicional (geralmente o botão direito do mouse), que pode ser reconhecido passando para o Input.GetMouseButtonDownvalor 1.

  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } else if (Input.GetMouseButtonDown(1)) { HandleAlternativeTouch(); } } void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleDestination(tile); } } void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleWall(tile); } } 


Agora nós temos as paredes.

Por que obtenho grandes espaços entre as sombras das paredes adjacentes na diagonal?
, , , . , , far clipping plane . , far plane 20 . , MSAA, .

Vamos também garantir que os pontos de extremidade não possam substituir diretamente as paredes.

  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } } 

Bloqueio de pesquisa de caminho


Para que as paredes bloqueiem a pesquisa do caminho, basta não adicionar ladrilhos com paredes à borda da pesquisa. Isso pode ser feito forçando a GameTile.GrowPathTonão devolver peças com paredes. Mas o caminho ainda deve crescer na direção da parede, para que todos os ladrilhos no campo tenham um caminho. Isso é necessário porque é possível que um ladrilho com inimigos se torne repentinamente uma parede.

  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

Para garantir que todos os blocos tenham um caminho, eles GameBoard.FindPathsdevem verificar isso após a conclusão da pesquisa. Se não for esse o caso, o estado do campo é inválido e precisa ser retornado false. Não é necessário atualizar a visualização do caminho para estados inválidos, porque o campo retornará ao estado anterior.

  bool FindPaths () { … foreach (GameTile tile in tiles) { if (!tile.HasPath) { return false; } } foreach (GameTile tile in tiles) { tile.ShowPath(); } return true; } 


Paredes afetam o caminho.

Para garantir que as paredes tenham os caminhos corretos, você precisa tornar os cubos translúcidos.


Paredes transparentes.

Observe que o requisito de correção de todos os caminhos não permite que paredes incluam uma parte do campo na qual não há ponto final. Podemos dividir o mapa, mas apenas se houver pelo menos um ponto final em cada parte. Além disso, cada parede deve estar adjacente a um bloco ou ponto final vazio; caso contrário, não será possível ter um caminho. Por exemplo, é impossível fazer um bloco sólido de 3 × 3 paredes.

Esconder o caminho


A visualização dos caminhos nos permite ver como a pesquisa de caminhos funciona e garantir que ela esteja realmente correta. Mas não precisa ser mostrado ao jogador, ou pelo menos não necessariamente. Portanto, vamos fornecer a capacidade de desativar as setas. Isso pode ser feito adicionando ao GameTilemétodo geral HidePath, que simplesmente desativa sua seta.

  public void HidePath () { arrow.gameObject.SetActive(false); } 

O estado do mapeamento de caminho faz parte do estado do campo. Adicione um GameBoardcampo booleano ao padrão igual falsea rastrear seu estado, bem como uma propriedade comum como getter e setter. O levantador deve mostrar ou ocultar caminhos em todos os blocos.

  bool showPaths; public bool ShowPaths { get => showPaths; set { showPaths = value; if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } else { foreach (GameTile tile in tiles) { tile.HidePath(); } } } } 

Agora, o método FindPathsdeve mostrar caminhos atualizados apenas se a renderização estiver ativada.

  bool FindPaths () { … if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } return true; } 

Por padrão, a visualização do caminho está desativada. Desligue a seta na casa pré-fabricada.


A seta pré-fabricada está inativa por padrão.

Fazemos isso para que ele Gamealterne o estado de visualização quando uma tecla é pressionada. Seria lógico usar a tecla P, mas também é uma tecla de atalho para ativar / desativar o modo de jogo no editor do Unity. Como resultado, a visualização muda quando a tecla de atalho para sair do modo de jogo é usada, o que não parece muito bom. Então, vamos usar a tecla V (abreviação de visualização).


Sem flechas.

Grade de exibição


Quando as setas estão ocultas, fica difícil discernir a localização de cada peça. Vamos adicionar as linhas de grade. Baixar daí malha textura com um limite quadrado que pode ser usado como um único contorno azulejo.


Textura de malha.

Não adicionaremos essa textura individualmente a cada peça, mas a aplicaremos no chão. Mas tornaremos essa grade opcional, bem como a visualização de caminhos. Portanto, adicionaremos ao GameBoardcampo de configuração Texture2De selecionaremos uma textura de malha para ele.

  [SerializeField] Texture2D gridTexture = default; 


Campo com textura de malha.

Adicione outro campo booleano e uma propriedade para controlar o estado da visualização da grade. Nesse caso, o levantador deve alterar o material da terra, que pode ser implementado chamando a GetComponent<MeshRenderer>terra e obtendo acesso às propriedades do materialresultado. Se a grade precisar ser exibida, atribuiremos a mainTexturetextura da grade à propriedade do material. Caso contrário, atribua a ele null. Observe que, quando você altera a textura do material, duplicatas da instância do material serão criadas, tornando-se independentes do ativo do material.

  bool showGrid, showPaths; public bool ShowGrid { get => showGrid; set { showGrid = value; Material m = ground.GetComponent<MeshRenderer>().material; if (showGrid) { m.mainTexture = gridTexture; } else { m.mainTexture = null; } } } 

Vamos Gamemudar a visualização da grade com a tecla G.

  void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } } 

Além disso, adicione a visualização de malha padrão em Awake.

  void Awake () { board.Initialize(boardSize, tileContentFactory); board.ShowGrid = true; } 


Grade não escalada.

Até agora, temos uma borda ao redor de todo o campo. Combina com a textura, mas não é disso que precisamos. Precisamos escalar a textura principal do material para que ele corresponda ao tamanho da grade. Você pode fazer isso chamando o método SetTextureScalematerial com o nome da propriedade de textura ( _MainTex ) e tamanho bidimensional. Podemos usar diretamente o tamanho do campo, que é convertido indiretamente em um valor Vector2.

  if (showGrid) { m.mainTexture = gridTexture; m.SetTextureScale("_MainTex", size); } 

sem

com

Grade em escala com a visualização de caminho desativada e ativada.

Então, nesse estágio, conseguimos um campo funcional para um jogo de peças do gênero de defesa de torre. No próximo tutorial, adicionaremos inimigos.

Repositório de

PDF

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


All Articles