Criando Tower Defense na Unidade: Inimigos

[ Primeira parte: lado a lado e encontrando o caminho ]

  • Colocação de pontos de criação do inimigo.
  • A aparência dos inimigos e seu movimento pelo campo.
  • Criando movimento suave a uma velocidade constante.
  • Mude o tamanho, velocidade e posicionamento dos inimigos.

Esta é a segunda parte de um tutorial sobre um jogo simples de defesa de torre . Ele examina o processo de criação de inimigos e seu movimento para o ponto final mais próximo.

Este tutorial é feito no Unity 2018.3.0f2.


Inimigos no caminho para o ponto final.

Pontos de criação inimiga (spawn)


Antes de começarmos a criar inimigos, precisamos decidir onde colocá-los em campo. Para fazer isso, criaremos pontos de desova.

Conteúdo em bloco


Um ponto de desova é outro tipo de conteúdo de bloco, portanto, adicione uma entrada no GameTileContentType .

 public enum GameTileContentType { Empty, Destination, Wall, SpawnPoint } 

E então crie uma pré-fabricada para visualizá-la. Uma duplicata da pré-fabricada do ponto de partida é bastante adequada para nós, basta alterar seu tipo de conteúdo e fornecer outro material. Eu fiz laranja.


Configuração do ponto de geração.

Adicione suporte ao ponto de reprodução à fábrica de conteúdo e vincule-o à pré-fabricada.

  [SerializeField] GameTileContent spawnPointPrefab = 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); case GameTileContentType.SpawnPoint: return Get(spawnPointPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 


Fábrica com suporte para pontos de desova.

Ativar ou desativar pontos de reprodução


O método para mudar o estado do ponto de desova, como outros métodos de troca, adicionaremos ao GameBoard . Mas os pontos de reprodução não afetam a pesquisa do caminho, portanto, após a alteração, não precisamos procurar novos caminhos.

  public void ToggleSpawnPoint (GameTile tile) { if (tile.Content.Type == GameTileContentType.SpawnPoint) { tile.Content = contentFactory.Get(GameTileContentType.Empty); } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint); } } 

O jogo só faz sentido se tivermos inimigos e eles precisarem de pontos de reprodução. Portanto, o campo de jogo deve conter pelo menos um ponto de reprodução. Também precisaremos de acesso aos pontos de desova no futuro, quando adicionarmos inimigos, então vamos usar a lista para rastrear todas as peças com esses pontos. Atualizaremos a lista ao mudar o estado do ponto de desova e impediremos a remoção do último ponto de desova.

  List<GameTile> spawnPoints = new List<GameTile>(); … public void ToggleSpawnPoint (GameTile tile) { if (tile.Content.Type == GameTileContentType.SpawnPoint) { if (spawnPoints.Count > 1) { spawnPoints.Remove(tile); tile.Content = contentFactory.Get(GameTileContentType.Empty); } } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.SpawnPoint); spawnPoints.Add(tile); } } 

O método Initialize agora deve definir o ponto de desova para criar o estado correto inicial do campo. Vamos apenas incluir o primeiro bloco, que fica no canto inferior esquerdo.

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

Vamos fazer o toque alternativo agora mudar o estado dos pontos de reprodução, mas quando você pressiona a tecla Shift esquerda (a combinação de teclas é verificada pelo método Input.GetKey ), o estado do ponto final muda

  void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { if (Input.GetKey(KeyCode.LeftShift)) { board.ToggleDestination(tile); } else { board.ToggleSpawnPoint(tile); } } } 


Campo com pontos de desova.

Obtenha acesso aos pontos de desova


O campo lida com todos os seus ladrilhos, mas os inimigos não são de sua responsabilidade. GetSpawnPoint possível acessar seus pontos de GetSpawnPoint através do método GetSpawnPoint comum com um parâmetro index.

  public GameTile GetSpawnPoint (int index) { return spawnPoints[index]; } 

Para saber quais índices estão corretos, são necessárias informações sobre o número de pontos de reprodução, portanto, tornaremos geral usando a propriedade getter geral.

  public int SpawnPointCount => spawnPoints.Count; 

Aparição inimiga


Gerar um inimigo é um pouco semelhante à criação do conteúdo de uma peça. Criamos uma instância pré-fabricada através da fábrica, que depois colocamos no campo.

Fábricas


Criaremos uma fábrica de inimigos que colocará tudo o que criar em seu próprio palco. Essa funcionalidade é comum na fábrica que já possuímos, então vamos colocar o código na classe base comum GameObjectFactory . Vamos precisar apenas de um método CreateGameObjectInstance com um parâmetro de prefab comum, que cria e retorna uma instância e também gerencia toda a cena. Tornamos o método protected , ou seja, ele estará disponível apenas para a classe e todos os tipos que herdam dela. É tudo o que a classe faz: não se destina a ser usada como uma fábrica totalmente funcional. Portanto, marcamos como abstract , o que não nos permitirá criar instâncias de seus objetos.

 using UnityEngine; using UnityEngine.SceneManagement; public abstract class GameObjectFactory : ScriptableObject { Scene scene; protected T CreateGameObjectInstance<T> (T prefab) where T : MonoBehaviour { if (!scene.isLoaded) { if (Application.isEditor) { scene = SceneManager.GetSceneByName(name); if (!scene.isLoaded) { scene = SceneManager.CreateScene(name); } } else { scene = SceneManager.CreateScene(name); } } T instance = Instantiate(prefab); SceneManager.MoveGameObjectToScene(instance.gameObject, scene); return instance; } } 

Altere o GameTileContentFactory para que ele GameTileContentFactory esse tipo de fábrica e use CreateGameObjectInstance no método Get e remova o código de controle da cena.

 using UnityEngine; [CreateAssetMenu] public class GameTileContentFactory : GameObjectFactory { … //Scene contentScene; … GameTileContent Get (GameTileContent prefab) { GameTileContent instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; //MoveToFactoryScene(instance.gameObject); return instance; } //void MoveToFactoryScene (GameObject o) { // … //} } 

Depois disso, crie um novo tipo EnemyFactory que crie uma instância de uma pré-fabricada Enemy usando o método Get , juntamente com o método de Reclaim o acompanha.

 using UnityEngine; [CreateAssetMenu] public class EnemyFactory : GameObjectFactory { [SerializeField] Enemy prefab = default; public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } public void Reclaim (Enemy enemy) { Debug.Assert(enemy.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(enemy.gameObject); } } 

O novo tipo de Enemy inicialmente tinha apenas que acompanhar sua fábrica original.

 using UnityEngine; public class Enemy : MonoBehaviour { EnemyFactory originFactory; public EnemyFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } } 

Prefab


Os inimigos precisam de visualização, que pode ser qualquer coisa - um robô, uma aranha, um fantasma, algo mais simples, por exemplo, um cubo, que usamos. Mas, em geral, o inimigo tem um modelo 3D de qualquer complexidade. Para garantir seu suporte conveniente, usaremos o objeto raiz para a hierarquia pré-fabricada inimiga, à qual apenas o componente Enemy está anexado.


Raiz pré-fabricada

Vamos criar esse objeto como o único elemento filho, que será a raiz do modelo. Ele deve ter valores de unidade de transformação.


A raiz do modelo.

A tarefa dessa raiz do modelo é posicionar o modelo 3D em relação ao ponto de origem local das coordenadas do inimigo, para que ele o considere como um ponto de referência sobre o qual o inimigo está ou trava. No nosso caso, o modelo será um cubo padrão de meio tamanho, ao qual darei uma cor azul escura. Tornamos um filho da raiz do modelo e configuramos a posição Y para 0,25, para que ela fique no chão.


Modelo de cubo

Assim, a pré-fabricada do inimigo consiste em três objetos aninhados: a raiz da pré-fabricada, a raiz do modelo e o cubo. Pode parecer um fracasso para um cubo simples, mas esse sistema permite mover e animar qualquer inimigo sem se preocupar com seus recursos.


A hierarquia pré-fabricada do inimigo.

Vamos criar uma fábrica inimiga e atribuir uma pré-fabricada a ela.


Fábrica de ativos.

Colocando inimigos no campo


Para colocar inimigos em campo, o Game deve receber um link para a fábrica de inimigos. Como precisamos de muitos inimigos, adicionaremos uma opção de configuração para ajustar a velocidade da desova, expressa no número de inimigos por segundo. Um intervalo aceitável é de 0,1 a 10 com um valor padrão de 1.

  [SerializeField] EnemyFactory enemyFactory = default; [SerializeField, Range(0.1f, 10f)] float spawnSpeed = 1f; 


Jogo com uma fábrica inimiga e velocidade de desova 4.

Nós rastrearemos a progressão da desova no Update , aumentando a velocidade dos tempos delta. Se o valor de prggress exceder 1, então o decrementamos e SpawnEnemy o inimigo usando o novo método SpawnEnemy . Continuamos fazendo isso até o progresso exceder 1, caso a velocidade seja muito alta e o tempo de exibição seja muito longo, para que vários inimigos não sejam criados ao mesmo tempo.

  float spawnProgress; … void Update () { … spawnProgress += spawnSpeed * Time.deltaTime; while (spawnProgress >= 1f) { spawnProgress -= 1f; SpawnEnemy(); } } 

Não é necessário atualizar o progresso no FixedUpdate?
Sim, é possível, mas não são necessários horários precisos para o jogo de defesa. Vamos simplesmente atualizar o estado do jogo a cada quadro e fazê-lo funcionar bem o suficiente para qualquer delta de tempo.

Deixe SpawnEnemy obter um ponto de desova aleatório no campo e criar um inimigo nesse ladrilho. Vamos fornecer ao método SpawnOn o Enemy para se SpawnOn corretamente.

  void SpawnEnemy () { GameTile spawnPoint = board.GetSpawnPoint(Random.Range(0, board.SpawnPointCount)); Enemy enemy = enemyFactory.Get(); enemy.SpawnOn(spawnPoint); } 

Por enquanto, tudo o que o SpawnOn precisa fazer é definir sua própria posição igual ao centro do bloco. Como o modelo pré-fabricado está posicionado corretamente, o cubo inimigo estará no topo desse bloco.

  public void SpawnOn (GameTile tile) { transform.localPosition = tile.transform.localPosition; } 


Os inimigos aparecem nos pontos de desova.

Movendo inimigos


Depois que o inimigo aparecer, ele deve começar a se mover ao longo do caminho até o ponto final mais próximo. Para conseguir isso, você precisa animar os inimigos. Começamos com um simples deslizamento suave de ladrilho em ladrilho e depois dificultamos o movimento.

Coleção de inimigos


Para atualizar o status dos inimigos, usaremos a mesma abordagem usada na série de tutoriais sobre gerenciamento de objetos . Adicionamos ao Enemy método GameUpdate geral, que retorna informações sobre se ele está vivo, o que, nesta fase, sempre será verdadeiro. Por enquanto, basta fazê-lo avançar de acordo com o delta do tempo.

  public bool GameUpdate () { transform.localPosition += Vector3.forward * Time.deltaTime; return true; } 

Além disso, precisamos manter uma lista de inimigos vivos e atualizar todos eles, removendo-os da lista de inimigos mortos. Podemos colocar todo esse código em um Game , mas isolá-lo e criar um tipo de EnemyCollection . Esta é uma classe serializável que não herda de nada. Damos a ele um método geral para adicionar um inimigo e outro método para atualizar toda a coleção.

 using System.Collections.Generic; [System.Serializable] public class EnemyCollection { List<Enemy> enemies = new List<Enemy>(); public void Add (Enemy enemy) { enemies.Add(enemy); } public void GameUpdate () { for (int i = 0; i < enemies.Count; i++) { if (!enemies[i].GameUpdate()) { int lastIndex = enemies.Count - 1; enemies[i] = enemies[lastIndex]; enemies.RemoveAt(lastIndex); i -= 1; } } } } 

Agora o Game será suficiente para criar apenas uma dessas coleções, em cada quadro atualize-a e adicione inimigos criados a ela. Atualizaremos os inimigos imediatamente após a possível geração de um novo inimigo, para que a atualização ocorra instantaneamente.

  EnemyCollection enemies = new EnemyCollection(); … void Update () { … enemies.GameUpdate(); } … void SpawnEnemy () { … enemies.Add(enemy); } 


Os inimigos estão avançando.

Movimento ao longo do caminho


Os inimigos já estão se movendo, mas até agora não seguem o caminho. Para fazer isso, eles precisam saber para onde ir a seguir. Portanto, vamos dar ao GameTile propriedade getter comum para obter o próximo bloco no caminho.

  public GameTile NextTileOnPath => nextOnPath; 

Conhecendo o bloco do qual você deseja sair e o bloco no qual você precisa entrar, os inimigos podem determinar os pontos inicial e final para mover um bloco. O inimigo pode interpolar a posição entre esses dois pontos, rastreando seus movimentos. Após a conclusão da movimentação, esse processo é repetido para o próximo bloco. Mas os caminhos podem mudar a qualquer momento. Em vez de determinar para onde ir mais longe no processo de movimento, simplesmente continuamos a percorrer a rota planejada e verificá-la, atingindo o próximo bloco.

Deixe o Enemy rastrear os dois blocos para que não sejam afetados por uma mudança no caminho. Ele também rastreará as posições para que não precisemos recebê-las em cada quadro e acompanhará o processo de mudança.

  GameTile tileFrom, tileTo; Vector3 positionFrom, positionTo; float progress; 

Inicialize esses campos no SpawnOn . O primeiro ponto é o bloco do qual o inimigo está se movendo, e o ponto final é o próximo bloco no caminho. Isso pressupõe que o próximo bloco exista, a menos que o inimigo tenha sido criado no ponto final, o que deve ser impossível. Em seguida, armazenamos em cache as posições dos blocos e redefinimos o progresso. Não precisamos definir a posição do inimigo aqui, porque o método GameUpdate chamado no mesmo quadro.

  public void SpawnOn (GameTile tile) { //transform.localPosition = tile.transform.localPosition; Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this); tileFrom = tile; tileTo = tile.NextTileOnPath; positionFrom = tileFrom.transform.localPosition; positionTo = tileTo.transform.localPosition; progress = 0f; } 

O incremento de progresso será realizado no GameUpdate . Vamos adicionar um delta de tempo constante para que os inimigos se movam a uma velocidade de um bloco por segundo. Quando o progresso está completo, alteramos os dados para que To se torne o valor From e o novo To seja o próximo bloco no caminho. Então diminuímos o progresso. Quando os dados se tornam relevantes, interpolamos a posição do inimigo entre From e To . Como o interpolador é progresso, seu valor está necessariamente no intervalo de 0 e 1, para que possamos usar s Vector3.LerpUnclamped .

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; positionFrom = positionTo; positionTo = tileTo.transform.localPosition; progress -= 1f; } transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); return true; } 

Isso força os inimigos a seguirem o caminho, mas não agem ao atingir o ponto final. Portanto, antes de alterar as posições de From e To , você precisa comparar o próximo bloco no caminho com null . Nesse caso, chegamos ao ponto final e o inimigo terminou o movimento. Executamos a Reclaim e retornamos false .

  while (progress >= 1f) { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; if (tileTo == null) { OriginFactory.Reclaim(this); return false; } positionFrom = positionTo; positionTo = tileTo.transform.localPosition; progress -= 1f; } 



Os inimigos seguem o caminho mais curto.

Agora os inimigos estão se movendo do centro de um bloco para outro. Vale a pena considerar que eles mudam seu estado de movimento apenas no centro dos ladrilhos, portanto, não podem responder imediatamente às mudanças no campo. Isso significa que, às vezes, os inimigos se movem pelas paredes que acabamos de definir. Uma vez que eles começaram a se mover em direção à célula, nada os impedia. É por isso que as paredes também precisam de caminhos reais.


Os inimigos reagem à mudança de caminhos.

Movimento de borda a borda


O movimento entre os centros dos ladrilhos e uma mudança acentuada de direção parece normal para um jogo abstrato no qual os inimigos estão movendo cubos, mas geralmente o movimento suave parece mais bonito. O primeiro passo para sua implementação não é mover-se pelos centros, mas pelas bordas dos ladrilhos.

O ponto da aresta entre os blocos adjacentes pode ser encontrado pela média de suas posições. Em vez de calculá-lo a cada passo de cada inimigo, só o calcularemos quando alterar o caminho em GameTile.GrowPathTo . Torne-o disponível usando a propriedade ExitPoint .

  public Vector3 ExitPoint { get; private set; } … GameTile GrowPathTo (GameTile neighbor) { … neighbor.ExitPoint = (neighbor.transform.localPosition + transform.localPosition) * 0.5f; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

O único caso especial é a célula final, cujo ponto de saída será o seu centro.

  public void BecomeDestination () { distance = 0; nextOnPath = null; ExitPoint = transform.localPosition; } 

Troque o Enemy para que ele use pontos de saída, não centros de lado a lado.

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … positionTo = tileFrom.ExitPoint; progress -= 1f; } transform.localPosition = Vector3.Lerp(positionFrom, positionTo, progress); return true; } public void SpawnOn (GameTile tile) { … positionTo = tileFrom.ExitPoint; progress = 0f; } 


Os inimigos se movem entre as bordas.

Um efeito colateral dessa mudança é que, quando os inimigos se viram devido a uma mudança no caminho, eles permanecem imóveis por um segundo.


Ao virar, os inimigos param.

Orientação


Embora os inimigos se movam pelos caminhos até mudar de orientação. Para que possam olhar na direção do movimento, precisam saber a direção do caminho que estão seguindo. Também determinaremos isso durante a busca de maneiras, para que isso não precise ser feito pelos inimigos.

Temos quatro direções: norte, leste, sul e oeste. Vamos enumerá-los.

 public enum Direction { North, East, South, West } 

Em seguida, fornecemos a propriedade GameTile para armazenar a direção do seu caminho.

  public Direction PathDirection { get; private set; } 

Adicione um parâmetro de direção ao GrowTo , que define a propriedade. Como estamos desenvolvendo um caminho do começo ao fim, a direção será oposta à de onde estamos desenvolvendo o caminho.

  public GameTile GrowPathNorth () => GrowPathTo(north, Direction.South); public GameTile GrowPathEast () => GrowPathTo(east, Direction.West); public GameTile GrowPathSouth () => GrowPathTo(south, Direction.North); public GameTile GrowPathWest () => GrowPathTo(west, Direction.East); GameTile GrowPathTo (GameTile neighbor, Direction direction) { … neighbor.PathDirection = direction; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; } 

Precisamos converter as direções em curvas expressas como quaternions. Seria conveniente se pudéssemos chamar GetRotation para a direção, então vamos fazer isso criando um método de extensão. Adicione o método estático geral DirectionExtensions , forneça uma matriz para armazenar em cache os quaternions necessários, bem como o método GetRotation para retornar o valor de direção correspondente. Nesse caso, faz sentido colocar a classe de extensão no mesmo arquivo que o tipo de enumeração.

 using UnityEngine; public enum Direction { North, East, South, West } public static class DirectionExtensions { static Quaternion[] rotations = { Quaternion.identity, Quaternion.Euler(0f, 90f, 0f), Quaternion.Euler(0f, 180f, 0f), Quaternion.Euler(0f, 270f, 0f) }; public static Quaternion GetRotation (this Direction direction) { return rotations[(int)direction]; } } 

O que é um método de extensão?
Um método de extensão é um método estático dentro de uma classe estática que se comporta como um método de instância de algum tipo. Esse tipo pode ser uma classe, interface, estrutura, valor primitivo ou enumeração. O primeiro argumento para o método de extensão deve ter a this . Ele define o valor do tipo e instância com os quais o método funcionará. Essa abordagem significa que as propriedades de expansão não são possíveis.

Isso permite que você adicione métodos a alguma coisa? Sim, assim como você pode escrever qualquer método estático cujo parâmetro seja qualquer tipo.

Agora podemos girar o Enemy ao gerar e toda vez que entramos em um novo bloco. Depois de atualizar os dados, o bloco From fornece orientação.

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … transform.localRotation = tileFrom.PathDirection.GetRotation(); progress -= 1f; } transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); return true; } public void SpawnOn (GameTile tile) { … transform.localRotation = tileFrom.PathDirection.GetRotation(); progress = 0f; } 

Mudança de direção


Em vez de mudar instantaneamente de direção, é melhor interpolar valores entre as curvas, semelhante à maneira como interpolamos entre posições. Para passar de uma orientação para outra, precisamos saber a mudança de direção que precisa ser feita: sem virar, virar à direita, virar à esquerda ou voltar. Adicionamos para isso uma enumeração, que novamente pode ser colocada no mesmo arquivo que a Direction , porque elas são pequenas e estão intimamente relacionadas.

 public enum Direction { North, East, South, West } public enum DirectionChange { None, TurnRight, TurnLeft, TurnAround } 

Adicione outro método de extensão, desta vez GetDirectionChangeTo , que retorna uma mudança de direção da direção atual para a próxima. Se as direções coincidirem, não haverá mudança. Se o próximo for mais do que o atual, então esta é uma curva para a direita. Porém, como as instruções são repetidas, a mesma situação ocorrerá quando a próxima for três a menos que a atual. Com uma curva à esquerda, será a mesma, apenas adição e subtração mudarão de lugar. O único caso restante é uma volta.

  public static DirectionChange GetDirectionChangeTo ( this Direction current, Direction next ) { if (current == next) { return DirectionChange.None; } else if (current + 1 == next || current - 3 == next) { return DirectionChange.TurnRight; } else if (current - 1 == next || current + 3 == next) { return DirectionChange.TurnLeft; } return DirectionChange.TurnAround; } 

Como fazemos uma rotação em apenas uma dimensão, a interpolação linear de ângulos será suficiente para nós. Adicione outro método de expansão que obtenha o ângulo de direção em graus.

  public static float GetAngle (this Direction direction) { return (float)direction * 90f; } 

Agora você Enemydeve acompanhar a direção, a mudança de direção e os ângulos entre os quais você precisa executar a interpolação.

  Direction direction; DirectionChange directionChange; float directionAngleFrom, directionAngleTo; 

SpawnOnficando mais difícil, então vamos mudar o código de preparação do estado para outro método. Designaremos o estado inicial do inimigo como um estado introdutório, assim o chamaremos PrepareIntro. Nesse estado, o inimigo se move do centro para a borda do seu ladrilho inicial, para que não haja mudança de direção. Os ângulos Frome Too mesmo.

  public void SpawnOn (GameTile tile) { Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this); tileFrom = tile; tileTo = tile.NextTileOnPath; //positionFrom = tileFrom.transform.localPosition; //positionTo = tileFrom.ExitPoint; //transform.localRotation = tileFrom.PathDirection.GetRotation(); progress = 0f; PrepareIntro(); } void PrepareIntro () { positionFrom = tileFrom.transform.localPosition; positionTo = tileFrom.ExitPoint; direction = tileFrom.PathDirection; directionChange = DirectionChange.None; directionAngleFrom = directionAngleTo = direction.GetAngle(); transform.localRotation = direction.GetRotation(); } 

Nesta fase, criamos algo como uma pequena máquina de estado. Para simplificar GameUpdate, mova o código de status para um novo método PrepareNextState. Vamos deixar apenas as trocas das peças Frome To, porque as usamos aqui para verificar se o inimigo terminou o caminho.

  public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { … //positionFrom = positionTo; //positionTo = tileFrom.ExitPoint; //transform.localRotation = tileFrom.PathDirection.GetRotation(); progress -= 1f; PrepareNextState(); } … } 

Ao fazer a transição para um novo estado, você sempre precisa mudar de posição, encontrar uma mudança de direção, atualizar a direção atual e mudar o ângulo Topara From. Já não marcamos uma curva.

  void PrepareNextState () { positionFrom = positionTo; positionTo = tileFrom.ExitPoint; directionChange = direction.GetDirectionChangeTo(tileFrom.PathDirection); direction = tileFrom.PathDirection; directionAngleFrom = directionAngleTo; } 

Outras ações dependem de uma mudança de direção. Vamos adicionar um método para cada opção. No caso de avançarmos, o ângulo Tocoincide com a direção do caminho da célula atual. Além disso, precisamos definir a rotação para que o inimigo esteja olhando para a frente.

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); } 

No caso de uma virada, não viramos instantaneamente. Precisamos interpolar para um ângulo diferente: 90 ° mais para virar à direita, 90 ° menos para virar à esquerda e 180 ° mais para voltar. Para evitar girar na direção errada devido a uma alteração nos valores de ângulo de 359 ° a 0 °, o ângulo Todeve ser indicado em relação à direção atual. Não precisamos nos preocupar que o ângulo fique abaixo de 0 ° ou mais de 360 ​​°, porque podemos Quaternion.Eulerlidar com isso.

  void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; } 

No final, PrepareNextStatepodemos usar switchpara alterar as direções para decidir qual dos quatro métodos chamar.

  void PrepareNextState () { … switch (directionChange) { case DirectionChange.None: PrepareForward(); break; case DirectionChange.TurnRight: PrepareTurnRight(); break; case DirectionChange.TurnLeft: PrepareTurnLeft(); break; default: PrepareTurnAround(); break; } } 

Agora, no final GameUpdate, precisamos verificar se a direção mudou. Nesse caso, interpole entre os dois cantos e defina a rotação.

  public bool GameUpdate () { … transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); if (directionChange != DirectionChange.None) { float angle = Mathf.LerpUnclamped( directionAngleFrom, directionAngleTo, progress ); transform.localRotation = Quaternion.Euler(0f, angle, 0f); } return true; } 


Os inimigos estão virando.

Movimento de curva


Podemos melhorar o movimento fazendo com que os inimigos se movam ao longo de uma curva ao girar. Em vez de caminhar de uma ponta a outra dos ladrilhos, deixe-os andar um quarto de círculo. O centro deste círculo fica em um canto comum aos ladrilhos Frome Tona mesma borda ao longo da qual o inimigo entrou no ladrilho From.


Uma rotação de quarto de círculo para virar à direita.

Podemos perceber isso movendo o inimigo em um arco usando trigonometria, enquanto ao mesmo tempo o giramos. Mas isso pode ser simplificado usando apenas rotação, movendo temporariamente a origem local das coordenadas do inimigo para o centro do círculo. Para fazer isso, precisamos mudar a posição do modelo inimigo, para fornecer um Enemylink para esse modelo, acessível através do campo de configuração.

  [SerializeField] Transform model = default; 


Inimigo com referência ao modelo.

Em preparação para avançar ou recuar, o modelo deve passar para a posição padrão, para a origem local das coordenadas do inimigo. Caso contrário, o modelo deve ser deslocado pela metade da unidade de medida - o raio do círculo de rotação, longe do ponto de virada.

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); model.localPosition = Vector3.zero; } void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(-0.5f, 0f); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(0.5f, 0f); } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = Vector3.zero; } 

Agora, o próprio inimigo precisa ser movido para o ponto de virada. Para fazer isso, também deve ser movido metade da unidade de medida, mas o deslocamento exato depende da direção. Vamos adicionar Directionum método de extensão auxiliar a isso GetHalfVector.

  static Vector3[] halfVectors = { Vector3.forward * 0.5f, Vector3.right * 0.5f, Vector3.back * 0.5f, Vector3.left * 0.5f }; … public static Vector3 GetHalfVector (this Direction direction) { return halfVectors[(int)direction]; } 

Adicione o vetor correspondente ao girar para a direita ou esquerda.

  void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(-0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); } 

E ao voltar, a posição deve ser o ponto de partida habitual.

  void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = Vector3.zero; transform.localPosition = positionFrom; } 

Além disso, ao calcular o ponto de saída, podemos usar GameTile.GrowPathTometade do vetor para não precisar de acesso às duas posições dos blocos.

  neighbor.ExitPoint = neighbor.transform.localPosition + direction.GetHalfVector(); 

Agora, ao mudar de direção, não precisamos interpolar a posição Enemy.GameUpdate, porque a rotação está envolvida no movimento.

  public bool GameUpdate () { … if (directionChange == DirectionChange.None) { transform.localPosition = Vector3.LerpUnclamped(positionFrom, positionTo, progress); } //if (directionChange != DirectionChange.None) { else { float angle = Mathf.LerpUnclamped( directionAngleFrom, directionAngleTo, progress ); transform.localRotation = Quaternion.Euler(0f, angle, 0f); } return true; } 


Os inimigos se dobram suavemente nos cantos.

Velocidade constante


Até esse ponto, a velocidade dos inimigos sempre foi igual a um bloco por segundo, independentemente de como eles se movem dentro do bloco. Mas a distância que eles cobrem depende de sua condição; portanto, sua velocidade, expressa em unidades por segundo, varia. Para que essa velocidade seja constante, precisamos alterar a velocidade do progresso, dependendo do estado. Portanto, adicione o campo multiplicador de progresso e use-o para dimensionar o delta GameUpdate.

  float progress, progressFactor; … public bool GameUpdate () { progress += Time.deltaTime * progressFactor; … } 

Mas se o progresso mudar dependendo do estado, o valor restante do progresso não poderá ser usado diretamente para o próximo estado. Portanto, antes de nos preparar para um novo estado, precisamos normalizar o progresso e aplicar o novo multiplicador já em um novo estado.

  public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 1f) { … //progress -= 1f; progress = (progress - 1f) / progressFactor; PrepareNextState(); progress *= progressFactor; } … } 

Avançar não requer mudanças, portanto, usa um fator de 1. Ao virar para a direita ou para a esquerda, o inimigo passa um quarto de círculo com um raio de ½, então a distância percorrida é ¼π. progressigual a um dividido por esse valor. Voltar não deve demorar muito, portanto, dobre o progresso para que demore meio segundo. Finalmente, o movimento introdutório cobre apenas metade da peça, portanto, para manter uma velocidade constante, seu progresso também precisa ser dobrado.

  void PrepareForward () { … progressFactor = 1f; } void PrepareTurnRight () { … progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnLeft () { … progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnAround () { … progressFactor = 2f; } void PrepareIntro () { … progressFactor = 2f; } 

Por que a distância é igual a 1/4 * pi?
A circunferência é 2π vezes o raio. Girar para a direita ou esquerda cobre apenas um quarto desse comprimento, e o raio é ½, então a distância é ½π × ½.

Estado final


Como temos um estado introdutório, vamos adicionar um final. No momento, os inimigos estão desaparecendo imediatamente após atingir o ponto final, mas vamos adiar o desaparecimento deles até chegarem ao centro do bloco final. Vamos criar um método para isso PrepareOutro, defina o movimento para frente, mas apenas para o centro do bloco com progresso duplicado para manter uma velocidade constante.

  void PrepareOutro () { positionTo = tileFrom.transform.localPosition; directionChange = DirectionChange.None; directionAngleTo = direction.GetAngle(); model.localPosition = Vector3.zero; transform.localRotation = direction.GetRotation(); progressFactor = 2f; } 

Para GameUpdatenão destruir o inimigo muito cedo, removeremos a mudança de lado a lado. Ele fará isso agora PrepareNextState. Assim, a verificação de nullretornos truesomente após o final do estado final.

  public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 1f) { //tileFrom = tileTo; //tileTo = tileTo.NextTileOnPath; if (tileTo == null) { OriginFactory.Reclaim(this); return false; } … } … } 

Em PrepareNextStatevamos começar com a mudança de peças. Depois de definir a posição From, mas antes de definir a posição, Toverificaremos se o bloco é igual ao Tovalor null. Nesse caso, prepare o estado final e pule o restante do método.

  void PrepareNextState () { tileFrom = tileTo; tileTo = tileTo.NextTileOnPath; positionFrom = positionTo; if (tileTo == null) { PrepareOutro(); return; } positionTo = tileFrom.ExitPoint; … } 


Inimigos com velocidade constante e estado final.

Variabilidade Inimiga


Temos um fluxo de inimigos, e eles são todos do mesmo cubo, movendo-se na mesma velocidade. O resultado é mais como uma cobra longa do que inimigos individuais. Vamos torná-los mais diferentes, randomizando seu tamanho, deslocamento e velocidade.

Faixa de valor flutuante


Mudaremos os parâmetros dos inimigos, escolhendo aleatoriamente suas características na faixa de valores. A estrutura FloatRangeque criamos no artigo Gerenciamento de objetos, Configurando formas , será útil aqui , então vamos copiá-la. As únicas alterações foram adicionar um construtor com um parâmetro e abrir o acesso ao mínimo e ao máximo usando propriedades readonly, para que o intervalo fosse imutável.

 using UnityEngine; [System.Serializable] public struct FloatRange { [SerializeField] float min, max; public float Min => min; public float Max => max; public float RandomValueInRange { get { return Random.Range(min, max); } } public FloatRange(float value) { min = max = value; } public FloatRange (float min, float max) { this.min = min; this.max = max < min ? min : max; } } 

Também copiamos o atributo definido para limitar seu intervalo.

 using UnityEngine; public class FloatRangeSliderAttribute : PropertyAttribute { public float Min { get; private set; } public float Max { get; private set; } public FloatRangeSliderAttribute (float min, float max) { Min = min; Max = max < min ? min : max; } } 

Precisamos apenas da visualização do controle deslizante, então copie-o FloatRangeSliderDrawerpara a pasta Editor .

 using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(FloatRangeSliderAttribute))] public class FloatRangeSliderDrawer : PropertyDrawer { public override void OnGUI ( Rect position, SerializedProperty property, GUIContent label ) { int originalIndentLevel = EditorGUI.indentLevel; EditorGUI.BeginProperty(position, label, property); position = EditorGUI.PrefixLabel( position, GUIUtility.GetControlID(FocusType.Passive), label ); EditorGUI.indentLevel = 0; SerializedProperty minProperty = property.FindPropertyRelative("min"); SerializedProperty maxProperty = property.FindPropertyRelative("max"); float minValue = minProperty.floatValue; float maxValue = maxProperty.floatValue; float fieldWidth = position.width / 4f - 4f; float sliderWidth = position.width / 2f; position.width = fieldWidth; minValue = EditorGUI.FloatField(position, minValue); position.x += fieldWidth + 4f; position.width = sliderWidth; FloatRangeSliderAttribute limit = attribute as FloatRangeSliderAttribute; EditorGUI.MinMaxSlider( position, ref minValue, ref maxValue, limit.Min, limit.Max ); position.x += sliderWidth + 4f; position.width = fieldWidth; maxValue = EditorGUI.FloatField(position, maxValue); if (minValue < limit.Min) { minValue = limit.Min; } if (maxValue < minValue) { maxValue = minValue; } else if (maxValue > limit.Max) { maxValue = limit.Max; } minProperty.floatValue = minValue; maxProperty.floatValue = maxValue; EditorGUI.EndProperty(); EditorGUI.indentLevel = originalIndentLevel; } } 

Escala do modelo


Começaremos mudando a escala do inimigo. Adicione EnemyFactoryas configurações de escala à opção. O intervalo de escala não deve ser muito grande, mas suficiente para criar variedades em miniatura e gigantescas de inimigos. Qualquer coisa dentro de 0,5–2 com um valor padrão de 1. Vamos escolher uma escala aleatória nesse intervalo Gete passá-la ao inimigo através de um novo método Initialize.

  [SerializeField, FloatRangeSlider(0.5f, 2f)] FloatRange scale = new FloatRange(1f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize(scale.RandomValueInRange); return instance; } 

O método Enemy.Initializesimplesmente define a escala do modelo que é a mesma em todas as dimensões.

  public void Initialize (float scale) { model.localScale = new Vector3(scale, scale, scale); } 

inspetor

cena

O intervalo de escalas é de 0,5 a 1,5.

Deslocamento do caminho


Para destruir ainda mais a uniformidade do fluxo de inimigos, podemos mudar sua posição relativa dentro dos ladrilhos. Eles avançam, então a mudança nessa direção altera apenas o tempo de seus movimentos, o que não é muito perceptível. Portanto, vamos deslocá-los para o lado, longe do caminho ideal que passa pelos centros dos ladrilhos. Adicione um EnemyFactorydeslocamento de caminho ao intervalo e passe o deslocamento aleatório para o método Initialize. O deslocamento pode ser negativo ou positivo, mas nunca superior a ½, porque isso moveria o inimigo para um bloco vizinho. Além disso, não queremos que os inimigos ultrapassem os ladrilhos que seguem, portanto, o intervalo será menor, por exemplo, 0,4, mas os verdadeiros limites dependem do tamanho do inimigo.

  [SerializeField, FloatRangeSlider(-0.4f, 0.4f)] FloatRange pathOffset = new FloatRange(0f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize( scale.RandomValueInRange, pathOffset.RandomValueInRange ); return instance; } 

Como o deslocamento do caminho afeta o caminho percorrido, Enemyé necessário rastreá-lo.

  float pathOffset; … public void Initialize (float scale, float pathOffset) { model.localScale = new Vector3(scale, scale, scale); this.pathOffset = pathOffset; } 

Ao mover exatamente em linha reta (durante o movimento de introdução, final ou normal), simplesmente aplicamos o deslocamento diretamente no modelo. A mesma coisa acontece quando você volta. Com uma curva à direita ou à esquerda, já deslocamos o modelo, que se torna relativo ao deslocamento do caminho.

  void PrepareForward () { transform.localRotation = direction.GetRotation(); directionAngleTo = direction.GetAngle(); model.localPosition = new Vector3(pathOffset, 0f); progressFactor = 1f; } void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; model.localPosition = new Vector3(pathOffset - 0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; model.localPosition = new Vector3(pathOffset + 0.5f, 0f); transform.localPosition = positionFrom + direction.GetHalfVector(); progressFactor = 1f / (Mathf.PI * 0.25f); } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; model.localPosition = new Vector3(pathOffset, 0f); transform.localPosition = positionFrom; progressFactor = 2f; } void PrepareIntro () { … model.localPosition = new Vector3(pathOffset, 0f); transform.localRotation = direction.GetRotation(); progressFactor = 2f; } void PrepareOutro () { … model.localPosition = new Vector3(pathOffset, 0f); transform.localRotation = direction.GetRotation(); progressFactor = 2f; } 

Como o deslocamento do caminho durante a rotação altera o raio, precisamos alterar o processo de cálculo do multiplicador de progresso. O deslocamento do caminho deve ser subtraído de ½ para obter o raio da curva para a direita e adicionado no caso de uma curva para a esquerda.

  void PrepareTurnRight () { … progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f - pathOffset)); } void PrepareTurnLeft () { … progressFactor = 1f / (Mathf.PI * 0.5f * (0.5f + pathOffset)); } 

Também obtemos o raio de viragem ao girar 180 °. Nesse caso, cobrimos metade do círculo com um raio igual ao deslocamento do caminho, portanto a distância é π vezes o deslocamento. No entanto, isso não funciona quando o deslocamento é zero e, em pequenos deslocamentos, as curvas são muito rápidas. Para evitar curvas instantâneas, podemos forçar o raio mínimo para calcular a velocidade, digamos 0,2.

  void PrepareTurnAround () { directionAngleTo = directionAngleFrom + (pathOffset < 0f ? 180f : -180f); model.localPosition = new Vector3(pathOffset, 0f); transform.localPosition = positionFrom; progressFactor = 1f / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f)); } 

inspetor


O deslocamento do caminho está no intervalo de -0,25 a 0,25.

Note que agora os inimigos nunca mudam seu deslocamento relativo, mesmo quando estão girando. Portanto, o comprimento total do caminho para cada inimigo tem o seu.

Para impedir que os inimigos cheguem a ladrilhos vizinhos, é preciso também levar em consideração a escala máxima possível. Limitei apenas o tamanho a um valor máximo de 1; portanto, o deslocamento máximo permitido para o cubo é de 0,25. Se o tamanho máximo fosse 1,5, o deslocamento máximo deverá ser reduzido para 0,125.

Velocidade


A última coisa que randomizamos é a velocidade dos inimigos. Nós adicionamos mais um intervalo para ele EnemyFactorye transferiremos valor para a cópia criada do inimigo. Vamos torná-lo o segundo argumento para o método Initialize. Os inimigos não devem ser muito lentos ou rápidos para que o jogo não se torne trivialmente simples ou impossível. Vamos limitar o intervalo para 0,2-5. A velocidade é expressa em unidades por segundo, que corresponde a blocos por segundo apenas quando avançar.

  [SerializeField, FloatRangeSlider(0.2f, 5f)] FloatRange speed = new FloatRange(1f); [SerializeField, FloatRangeSlider(-0.4f, 0.4f)] FloatRange pathOffset = new FloatRange(0f); public Enemy Get () { Enemy instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; instance.Initialize( scale.RandomValueInRange, speed.RandomValueInRange, pathOffset.RandomValueInRange ); return instance; } 

Agora eu Enemytenho que rastrear e acelerar.

  float speed; … public void Initialize (float scale, float speed, float pathOffset) { model.localScale = new Vector3(scale, scale, scale); this.speed = speed; this.pathOffset = pathOffset; } 

Quando não definimos a velocidade explicitamente, simplesmente sempre usamos o valor 1. Agora, apenas precisamos criar a dependência do multiplicador de progresso na velocidade.

  void PrepareForward () { … progressFactor = speed; } void PrepareTurnRight () { … progressFactor = speed / (Mathf.PI * 0.5f * (0.5f - pathOffset)); } void PrepareTurnLeft () { … progressFactor = speed / (Mathf.PI * 0.5f * (0.5f + pathOffset)); } void PrepareTurnAround () { … progressFactor = speed / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f)); } void PrepareIntro () { … progressFactor = 2f * speed; } void PrepareOutro () { … progressFactor = 2f * speed; } 



Velocidade na faixa de 0,75 a 1,25.

Então, temos um belo fluxo de inimigos se movendo para o ponto final. No próximo tutorial, aprenderemos como lidar com eles. Quer saber quando será lançado? Siga minha página no Patreon ! artigo em PDF do

repositório

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


All Articles