Criando defesa de torre na unidade: balística

[ A primeira , segunda e terceira partes do tutorial]

  • Suporte para diferentes tipos de torres.
  • Criando uma torre de argamassa.
  • Cálculo de trajetórias parabólicas.
  • Lançamento de conchas explosivas.

Esta é a quarta parte de um tutorial sobre como criar um jogo simples de defesa de torre . Nele, adicionaremos torres de argamassa que disparam projéteis detonantes em uma colisão.

O tutorial foi criado no Unity 2018.4.4f1.


Inimigos são bombardeados.

Tipos de torres


Um laser não é o único tipo de arma que pode ser colocada em uma torre. Neste tutorial, adicionaremos o segundo tipo de torre, que disparará projéteis que explodem ao entrar em contato, danificando todos os inimigos próximos. Para isso, precisamos de suporte para vários tipos de torres.

Torre abstrata


A detecção e rastreamento de alvos é uma funcionalidade que qualquer torre pode usar, portanto, a colocaremos na classe base abstrata de torres. Para fazer isso, simplesmente usamos a classe Tower , mas primeiro duplicamos seu conteúdo para uso posterior em uma classe LaserTower específica. Em seguida, removemos todo o código relacionado ao laser da Tower . A torre pode não acompanhar um destino específico; portanto, exclua o campo de target e altere o AcquireTarget e o TrackTarget para que o parâmetro de saída seja usado como parâmetro de link. Em seguida, removeremos a visualização de OnDrawGizmosSelected do OnDrawGizmosSelected , mas deixaremos o intervalo de mira, pois é usado para todas as torres.

 using UnityEngine; public abstract class Tower : GameTileContent { const int enemyLayerMask = 1 << 9; static Collider[] targetsBuffer = new Collider[100]; [SerializeField, Range(1.5f, 10.5f)] protected float targetingRange = 1.5f; protected bool AcquireTarget (out TargetPoint target) { … } protected bool TrackTarget (ref TargetPoint target) { … } void OnDrawGizmosSelected () { Gizmos.color = Color.yellow; Vector3 position = transform.localPosition; position.y += 0.01f; Gizmos.DrawWireSphere(position, targetingRange); } } 

Altere a classe duplicada para que se transforme em uma LaserTower que estenda a Tower e use a funcionalidade de sua classe base, livrando-se do código duplicado.

 using UnityEngine; public class LaserTower : Tower { [SerializeField, Range(1f, 100f)] float damagePerSecond = 10f; [SerializeField] Transform turret = default, laserBeam = default; TargetPoint target; Vector3 laserBeamScale; void Awake () { laserBeamScale = laserBeam.localScale; } public override void GameUpdate () { if (TrackTarget(ref target) || AcquireTarget(out target)) { Shoot(); } else { laserBeam.localScale = Vector3.zero; } } void Shoot () { … } } 

Atualize a pré-fabricada da torre do laser para usar o novo componente.


Componente de uma torre de laser.

Criando um tipo específico de torre


Para poder selecionar quais torres serão colocadas no campo, adicionaremos uma enumeração TowerType semelhante ao GameTileContentType . Criaremos suporte para a torre a laser e a torre de argamassa existentes, que criaremos posteriormente.

 public enum TowerType { Laser, Mortar } 

Como criaremos uma classe para cada tipo de torre, adicionaremos uma propriedade getter abstrata à Tower para indicar seu tipo. Isso funciona de maneira semelhante ao tipo de comportamento de uma figura na série de tutoriais sobre gerenciamento de objetos .

  public abstract TowerType TowerType€ { get; } 

Redefina-o no LaserTower para que ele retorne o tipo correto.

  public override TowerType TowerType€ => TowerType.Laser; 

Em seguida, altere o GameTileContentFactory para que a fábrica possa produzir a torre do tipo desejado. Implementamos isso com uma matriz de torres e adicionamos um método Get público alternativo com o parâmetro TowerType . Para verificar se a matriz está configurada corretamente, usaremos asserções. Outro método Get público agora se aplicará apenas ao conteúdo de blocos sem torres.

  [SerializeField] Tower[] towerPrefabs = default; public GameTileContent Get (GameTileContentType type) { switch (type) { … } Debug.Assert(false, "Unsupported non-tower type: " + type); return null; } public GameTileContent Get (TowerType type) { Debug.Assert((int)type < towerPrefabs.Length, "Unsupported tower type!"); Tower prefab = towerPrefabs[(int)type]; Debug.Assert(type == prefab.TowerType€, "Tower prefab at wrong index!"); return Get(prefab); } 

Seria lógico retornar o tipo mais específico; portanto, idealmente, o tipo de retorno do novo método Get deve ser Tower . Mas o método Get privado usado para instanciar a prefab retorna um GameTileContent . Aqui você pode realizar a conversão ou tornar genérico o método Get privado. Vamos escolher a segunda opção.

  public Tower Get (TowerType type) { … } T Get<T> (T prefab) where T : GameTileContent { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } 

Embora tenhamos apenas uma torre a laser, faremos dela o único elemento da matriz de torres da fábrica.


Uma matriz de torres pré-fabricadas.

Criando instâncias de tipos de torre específicos


Para criar uma torre de um tipo específico, GameBoard.ToggleTower para que exija o parâmetro TowerType e o TowerType à fábrica.

  public void ToggleTower (GameTile tile, TowerType towerType) { if (tile.Content.Type == GameTileContentType.Tower€) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(towerType); … } else if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } } 

Isso cria uma nova oportunidade: o estado da torre muda quando já existe, mas as torres são de vários tipos. Até agora, a troca apenas remove a torre existente, mas seria lógico que ela fosse substituída por um novo tipo, então vamos implementar isso. Como o bloco permanece ocupado, você não precisa procurar o caminho novamente.

  if (tile.Content.Type == GameTileContentType.Tower€) { updatingContent.Remove(tile.Content); if (((Tower)tile.Content).TowerType€ == towerType) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(towerType); updatingContent.Add(tile.Content); } } 

Game agora deve rastrear o tipo de torre comutável. Simplesmente denotamos cada tipo de torre por um número. A torre do laser é 1, será a torre padrão e a torre da argamassa é 2. Ao pressionar as teclas numéricas, selecionaremos o tipo apropriado de torre.

  TowerType selectedTowerType; … void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } if (Input.GetKeyDown(KeyCode.Alpha1)) { selectedTowerType = TowerType.Laser; } else if (Input.GetKeyDown(KeyCode.Alpha2)) { selectedTowerType = TowerType.Mortar; } … } … void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { if (Input.GetKey(KeyCode.LeftShift)) { board.ToggleTower(tile, selectedTowerType); } else { board.ToggleWall(tile); } } } 

Torre de argamassa


Ainda não será possível colocar a torre da argamassa, porque ela ainda não possui uma casa pré-fabricada. Vamos começar criando um tipo mínimo de MortarTower . As argamassas têm uma frequência de tiro, para indicar qual você pode usar o campo de configuração "tiros por segundo". Além disso, precisaremos de um link para a argamassa para que ela possa mirar.

 using UnityEngine; public class MortarTower : Tower { [SerializeField, Range(0.5f, 2f)] float shotsPerSecond = 1f; [SerializeField] Transform mortar = default; public override TowerType TowerType€ => TowerType.Mortar; } 

Agora crie uma pré-fabricada para a torre de argamassa. Isso pode ser feito duplicando a pré-fabricada da torre a laser e substituindo seu componente. Então nos livramos dos objetos da torre e do raio laser. Renomeie a turret para mortar , mova-a para baixo e fique em cima da base, dê uma cor cinza clara e prenda-a. Podemos deixar o colisor de argamassa, nesse caso, usando um objeto separado, que é um colisor simples sobreposto à orientação padrão da argamassa. Atribuí uma faixa de argamassa de 3,5 e uma frequência de 1 tiro por segundo.

cena

hierarquia

inspetor

Casa pré-fabricada da torre de argamassa.

Por que eles são chamados de morteiros?
As primeiras variedades dessa arma foram essencialmente tigelas de ferro, semelhantes às argamassas, nas quais os ingredientes foram moídos com um pilão.

Adicione as argamassas pré-fabricadas à matriz de fábrica para que as torres de argamassa possam ser colocadas no campo. No entanto, eles não estão fazendo nada ainda.

inspetor

cena

Dois tipos de torres, uma delas inativa

Cálculo de trajetória


Mortira atira uma concha em um ângulo, para que ele voe sobre obstáculos e acerte o alvo de cima. Normalmente, são usadas conchas que detonam ao colidir com um alvo ou acima dele. Para não complicar as coisas, sempre apontaremos para o chão, para que as conchas explodam quando sua altura cai para zero.

Mira horizontal


Para apontar a argamassa, precisamos apontá-la horizontalmente para o alvo e depois mudar sua posição vertical para que o projétil caia na distância certa. Vamos começar com o primeiro passo. Primeiro, usaremos pontos relativos fixos, não alvos em movimento, para garantir que nossos cálculos estejam corretos.

Adicione um método MortarTower ao GameUpdate , que sempre chama o método Launch . Em vez de disparar um projétil real, visualizaremos cálculos matemáticos por enquanto. O ponto de tiro é a posição da argamassa no mundo, localizada logo acima do solo. Colocamos o ponto do alvo em três unidades ao longo do eixo X e zeramos o componente Y, porque sempre apontamos para o chão. Em seguida, mostraremos os pontos Debug.DrawLine linha amarela entre eles, chamando Debug.DrawLine . A linha será visível no modo de cena para um quadro, mas isso é suficiente, porque em cada quadro desenhamos uma nova linha.

  public override void GameUpdate () { Launch(); } public void Launch () { Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); Debug.DrawLine(launchPoint, targetPoint, Color.yellow); } 


Nosso objetivo é um ponto fixo em relação à torre.

Usando esta linha, podemos definir um triângulo retângulo. Seu ponto superior está na posição de argamassa. Em relação às argamassas, isso é  b e g i n b m a t r i x 00 e n d b m a t r i x  . O ponto abaixo, na base da torre, é  b e g i n b m a t r i x 0y e n d b m a t r i x  , e o ponto na meta é  b e g i n b m a t r i x xy e n d b m a t r i x  onde x igual a 3 e y É a posição vertical negativa da argamassa. Precisamos rastrear esses dois valores.

  Vector3 launchPoint = mortar.position; Vector3 targetPoint = new Vector3(launchPoint.x + 3f, 0f, launchPoint.z); float x = 3f; float y = -launchPoint.y; 


Triângulo apontando.

Em geral, o alvo pode estar em qualquer lugar do alcance da torre, portanto Z também deve ser levado em consideração. No entanto, o triângulo de mira ainda permanece bidimensional, ele simplesmente gira em torno do eixo Y. Para ilustrar isso, adicionaremos o parâmetro do vetor de deslocamento relativo no Launch e o chamaremos com quatro deslocamentos em XZ:  b e g i n b m a t r i x 30 e n d b m a t r i x  ,  b e g i n b m a t r i x 01 endbmatrix ,  beginbmatrix11 endbmatrix e  beginbmatrix31 endbmatrix . Quando o ponto de mira se torna igual ao ponto do tiro mais esse deslocamento, e então sua coordenada Y se torna igual a zero.

  public override void GameUpdate () { Launch(new Vector3(3f, 0f, 0f)); Launch(new Vector3(0f, 0f, 1f)); Launch(new Vector3(1f, 0f, 1f)); Launch(new Vector3(3f, 0f, 1f)); } public void Launch (Vector3 offset) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = launchPoint + offset; targetPoint.y = 0f; … } 

Agora x do triângulo de mira é igual ao comprimento do vetor 2D apontando da base da torre para o ponto de mira. Ao normalizar esse vetor, também obtemos o vetor de direção XZ, que pode ser usado para alinhar o triângulo. Você pode mostrá-lo desenhando a parte inferior do triângulo como uma linha branca obtida da direção ex.

  Vector2 dir; dir.x = targetPoint.x - launchPoint.x; dir.y = targetPoint.z - launchPoint.z; float x = dir.magnitude; float y = -launchPoint.y; dir /= x; Debug.DrawLine(launchPoint, targetPoint, Color.yellow); Debug.DrawLine( new Vector3(launchPoint.x, 0.01f, launchPoint.z), new Vector3( launchPoint.x + dir.x * x, 0.01f, launchPoint.z + dir.y * x ), Color.white ); 


Triângulos de mira alinhados.

Ângulo de tiro


Em seguida, devemos descobrir o ângulo em que atirar no projétil. É necessário derivá-lo da física da trajetória do projétil. Não levaremos em consideração o arrasto, vento e outros obstáculos, apenas a velocidade do tiro v e gravidade g=9,81 .

Deslocamento d o projétil está alinhado com o triângulo de mira e pode ser descrito por dois componentes. Com o deslocamento horizontal, é simples: é dx=vxt onde t - tempo após o tiro. Com o componente vertical, tudo é semelhante, então está sujeito a aceleração negativa devido à gravidade, portanto, tem a forma dy=vyt(gt2)/2 .

Como é realizado o cálculo do deslocamento?
Velocidade v determinada pela distância por segundo, portanto, multiplicando a velocidade pela duração t nós temos a distância d=vt . Quando a aceleração está envolvida a , a velocidade é variável. Aceleração é a mudança na velocidade por segundo, ou seja, a distância por segundo ao quadrado. A qualquer momento, a velocidade é v=em . No nosso caso, há aceleração constante a=g , para que possamos dividi-lo ao meio para obter a velocidade média e multiplicar pelo tempo para encontrar o deslocamento d=(em2)/2 causada pela gravidade.

Atiramos conchas na mesma velocidade s que não depende do ângulo do tiro  theta (teta). Isso é vx=s cos theta e vy=s sin theta .


Cálculo da velocidade de um tiro.

Realizando a substituição, obtemos dx=st cos theta e dy=st sin theta(gt2)/2 .

O projétil é disparado para que seu tempo de vôo t é o valor exato necessário para atingir a meta. Como é mais fácil trabalhar com deslocamento horizontal, podemos expressar o tempo como t=dx/vx . No ponto final dx=x isso é t = x / ( s c o s t h e t a )   . Isso significa que y = x t a n t h e t a - ( g x 2 ) / ( 2 s 2 c o s 2 t h e t a )     .

Como obter a equação y?
y=dy=s(x/(s cos theta)) sin theta(g(x/(s cos theta))2)/2=x sin theta/ cos Qualéaraizquadradade2?Matemática5ponto e  tan theta= sin theta/ cos theta .

Usando esta equação, encontramos  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) .
Como obter a equação tan θ?
Primeiro vamos usar a identidade trigonométrica  seg theta=1/ cos theta e 1+ tan2 theta= seg2 theta para vir para y=x tan theta(gx2)/(2s2)(1+ tan2 theta)=(gx2)/(2s2) tan2 theta+x tan theta(gx2)/(2s2) .

Esta é uma expressão do formulário au2+bu+c=0 onde u= tan theta , a=(gx2)/(2s2) , b=x e c=ay .

Podemos resolvê-lo usando a fórmula das raízes da equação quadrática u=(b+ sqrt(b24ac))/(2a) .

Após sua substituição, a equação se tornará confusa, mas você pode simplificá-la multiplicando pela m=s2/x então para obter  tan theta=(mb+m sqrtr)/(2ma) onde r=b24ac .

Nesse caso, obtemos  tan theta=(s2+ sqrt(m2r))/(gx) .

Como resultado A soma de dois algarismos distintos é igual a: a) x2 + x2 + x2 + x = 0 2) $ .

Existem dois ângulos possíveis, porque você pode mirar alto ou baixo. Uma trajetória baixa é mais rápida porque está mais próxima de uma linha reta do alvo. Mas a trajetória alta parece mais interessante, por isso a escolheremos. Isso significa que precisamos apenas usar a maior solução.  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) . Nós calculamos e também  cos theta com  sin theta , porque precisamos deles para obter o vetor de velocidade da foto. Para isso, você precisa converter  tan theta para o ângulo radiano usando Mathf.Atan . Primeiro, vamos usar uma velocidade constante de tiro de 5.

  float x = dir.magnitude; float y = -launchPoint.y; dir /= x; float g = 9.81f; float s = 5f; float s2 = s * s; float r = s2 * s2 - g * (g * x * x + 2f * y * s2); float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta; 

Vamos visualizar a trajetória desenhando dez segmentos azuis mostrando o primeiro segundo do voo.

  float sinTheta = cosTheta * tanTheta; Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { float t = i / 10f; float dx = s * cosTheta * t; float dy = s * sinTheta * t - 0.5f * g * t * t; next = launchPoint + new Vector3(dir.x * dx, dy, dir.y * dx); Debug.DrawLine(prev, next, Color.blue); prev = next; } 


Linhas de voo de Parábola com duração de um segundo.

Os dois pontos mais distantes podem ser alcançados em menos de um segundo, então vemos todas as suas trajetórias e os segmentos continuam um pouco mais abaixo da superfície. Para os outros dois pontos, são necessários ângulos de tiro maiores, devido aos quais as trajetórias se tornam mais longas e o vôo dura mais de um segundo.

Velocidade de tiro


Se você quiser alcançar os dois pontos mais próximos em um segundo, precisará reduzir a velocidade do tiro. Vamos torná-lo igual a 4.

  float s = 4f; 


Velocidade do tiro reduzida para 4.

Suas trajetórias estão agora completas, mas as outras duas se foram. Isso aconteceu porque a velocidade do tiro agora não é suficiente para alcançar esses pontos. Nesses casos, soluções para  tan theta não, ou seja, obtemos a raiz quadrada de um número negativo, levando a valores de NaN e ao desaparecimento de linhas. Podemos reconhecer isso verificando r à negatividade.

  float r = s2 * s2 - g * (g * x * x + 2f * y * s2); Debug.Assert(r >= 0f, "Launch velocity insufficient for range!"); 

Esta situação pode ser evitada definindo uma velocidade de tiro suficientemente alta. Mas se for muito grande, para atingir alvos próximos à torre exigirá trajetórias muito altas e um longo tempo de voo; portanto, você deve deixar a velocidade o mais baixa possível. A velocidade do tiro deve ser suficiente para atingir o alvo no alcance máximo.

Na faixa máxima r=0 , ou seja, para  tan theta Existe apenas uma solução, correspondendo a uma trajetória baixa. Isso significa que sabemos a velocidade necessária do tiro. s= sqrt(g(y+ sqrt(x2+y2))) .

Como derivar esta equação para s?
Precisa decidir Qual o valor de x na equação x + 2y = 0? Matemática5 pontos para s .

Esta é uma expressão do formulário au2+bu+c=0 onde u=s2 , a=1 , b=2gy e c=g2x2 .

Você pode resolvê-lo usando a fórmula simplificada das raízes da equação quadrática u=(b+ sqrt(b24c))/2 .

Após a substituição, obtemos s2=(2gy+ sqrt(4g2y2+4g2x2))/2=gy+g sqrt(x2+y2) .

Precisamos de uma solução positiva, então chegamos a s2=g(y+ sqrt(x2+y2)) .

Precisamos determinar a velocidade necessária somente quando as argamassas acordam (Desperta) ou quando alteramos seu alcance no modo Play. Portanto, nós o rastrearemos usando o campo e calcularemos em Awake e OnValidate .

  float launchSpeed; void Awake () { OnValidate(); } void OnValidate () { float x = targetingRange; float y = -mortar.position.y; launchSpeed = Mathf.Sqrt(9.81f * (y + Mathf.Sqrt(x * x + y * y))); } 

No entanto, devido a limitações na precisão dos cálculos de ponto flutuante, a determinação do alvo muito próximo da faixa máxima pode ser incorreta. Portanto, ao calcular a velocidade necessária, adicionamos uma pequena quantidade ao intervalo. Além disso, o raio do colisor do inimigo expande essencialmente o raio máximo do alcance da torre. Fizemos o valor igual a 0,125, mas com um aumento na escala do inimigo, ele pode dobrar o máximo possível, aumentando o alcance real em cerca de 0,25, por exemplo, em 0,25001.

  float x = targetingRange + 0.25001f; 

Em seguida, aplique a equação derivada para a velocidade de uma foto no Launch .

  float s = launchSpeed; 


Aplique a velocidade calculada na faixa de mira 3.5.

Tiro


Tendo o cálculo correto da trajetória, você pode se livrar das metas de teste relativas. Agora você precisa passar o ponto de Launch para o destino.

  public void Launch (TargetPoint target) { Vector3 launchPoint = mortar.position; Vector3 targetPoint = target.Position; targetPoint.y = 0f; … } 

Além disso, os tiros não são disparados em todos os quadros. Precisamos acompanhar o processo do tiro da mesma maneira que o processo de criação de inimigos e capturar um alvo aleatório quando chegar a hora do tiro no GameUpdate . Mas, neste momento, pode não haver objetivos disponíveis. Nesse caso, continuamos o processo de disparo, mas sem maior acúmulo. Para evitar um loop infinito, é necessário torná-lo um pouco menor que 1.

  float launchProgress; … public override void GameUpdate () { launchProgress += shotsPerSecond * Time.deltaTime; while (launchProgress >= 1f) { if (AcquireTarget(out TargetPoint target)) { Launch(target); launchProgress -= 1f; } else { launchProgress = 0.999f; } } } 

Não rastreamos alvos entre tiros, mas precisamos girar a argamassa corretamente durante os tiros. Você pode usar a direção horizontal da foto para girar a argamassa horizontalmente usando Quaternion.LookRotation . Também precisamos com  tan theta aplique o ângulo de tiro para o componente Y do vetor de direção. Isso funcionará porque a direção horizontal tem um comprimento de 1, ou seja,  tan theta= sin theta .


Decomposição do vetor de virada do visual.

  float tanTheta = (s2 + Mathf.Sqrt(r)) / (g * x); float cosTheta = Mathf.Cos(Mathf.Atan(tanTheta)); float sinTheta = cosTheta * tanTheta; mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y)); 

Para ainda ver a trajetória das capturas, você pode adicionar um parâmetro ao Debug.DrawLine que permita que elas sejam desenhadas por um longo tempo.

  Vector3 prev = launchPoint, next; for (int i = 1; i <= 10; i++) { … Debug.DrawLine(prev, next, Color.blue, 1f); prev = next; } Debug.DrawLine(launchPoint, targetPoint, Color.yellow, 1f); Debug.DrawLine( … Color.white, 1f ); 


Apontar.

Conchas


O significado de calcular trajetórias é que agora sabemos como disparar conchas. Em seguida, precisamos criá-los e matá-los.

Fábrica de guerra


Precisamos de uma fábrica para instanciar objetos de shell. Enquanto no ar, as conchas existem por conta própria e não são mais dependentes dos morteiros que as atingiram. Portanto, eles não devem ser processados ​​pela torre de argamassa e a fábrica de conteúdo de ladrilhos também não é adequada para isso.Vamos criar criar para tudo o que está relacionado às armas, uma nova fábrica e chamaremos de fábrica de guerra. Primeiro, crie um resumo WarEntitycom uma propriedade OriginFactorye um método Recycle.

 using UnityEngine; public abstract class WarEntity : MonoBehaviour { WarFactory originFactory; public WarFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); } } 

Em seguida, crie uma entidade específica Shellpara as conchas.

 using UnityEngine; public class Shell : WarEntity { } 

Em seguida, crie WarFactoryaquele que criará o projétil usando a propriedade getter pública.

 using UnityEngine; [CreateAssetMenu] public class WarFactory : GameObjectFactory { [SerializeField] Shell shellPrefab = default; public Shell Shell€ => Get(shellPrefab); T Get<T> (T prefab) where T : WarEntity { T instance = CreateGameObjectInstance(prefab); instance.OriginFactory = this; return instance; } public void Reclaim (WarEntity entity) { Debug.Assert(entity.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(entity.gameObject); } } 

Crie uma pré-fabricada para o projétil. Usei um cubo simples com a mesma escala de 0,25 e material escuro, além de um componente Shell. Em seguida, crie o ativo da fábrica e atribua a pré-fabricada do projétil.


Fábrica de guerra.

Comportamento do jogo


Para mover as conchas, elas precisam ser atualizadas. Você pode usar a mesma abordagem usada Gamepara atualizar o status dos inimigos. De fato, podemos até generalizar essa abordagem criando um componente abstrato GameBehaviorque estende MonoBehavioure adiciona um método virtual GameUpdate.

 using UnityEngine; public abstract class GameBehavior : MonoBehaviour { public virtual bool GameUpdate () => true; } 

Agora faça a refatoração EnemyCollection, transformando-a em GameBehaviorCollection.

 public class GameBehaviorCollection { List<GameBehavior> behaviors = new List<GameBehavior>(); public void Add (GameBehavior behavior) { behaviors.Add(behavior); } public void GameUpdate () { for (int i = 0; i < behaviors.Count; i++) { if (!behaviors[i].GameUpdate()) { int lastIndex = behaviors.Count - 1; behaviors[i] = behaviors[lastIndex]; behaviors.RemoveAt(lastIndex); i -= 1; } } } } 

Vamos fazê-lo WarEntityexpandir GameBehavior, não MonoBehavior.

 public abstract class WarEntity : GameBehavior { … } 

Faremos o mesmo por Enemy, desta vez, substituindo o método GameUpdate.

 public class Enemy : GameBehavior { … public override bool GameUpdate () { … } … } 

A partir de agora, Gameele terá que rastrear duas coleções, uma para inimigos e outra para não-inimigos. Os não inimigos devem ser atualizados depois de tudo o mais.

  GameBehaviorCollection enemies = new GameBehaviorCollection(); GameBehaviorCollection nonEnemies = new GameBehaviorCollection(); … void Update () { … enemies.GameUpdate(); Physics.SyncTransforms(); board.GameUpdate(); nonEnemies.GameUpdate(); } 

A etapa final na implementação de uma atualização do shell é adicioná-los a uma coleção de não inimigos. Vamos fazer isso com uma função Gameque será uma fachada estática para uma fábrica de guerra, para que projéteis possam ser criados por um desafio Game.SpawnShell(). Para que isso funcione, você Gamedeve ter um link para a war factory e acompanhar sua própria instância.

  [SerializeField] WarFactory warFactory = default; … static Game instance; public static Shell SpawnShell () { Shell shell = instance.warFactory.Shell€; instance.nonEnemies.Add(shell); return shell; } void OnEnable () { instance = this; } 


Jogo com fábrica de guerra.

Uma fachada estática é uma boa solução?
, , .

Atiramos em uma concha


Depois de criar uma instância do projétil, ele deve seguir seu caminho até atingir a meta final. Para fazer isso, adicione ao Shellmétodo Initializee use-o para especificar o ponto do tiro, o ponto do alvo e a velocidade do tiro.

  Vector3 launchPoint, targetPoint, launchVelocity; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity ) { this.launchPoint = launchPoint; this.targetPoint = targetPoint; this.launchVelocity = launchVelocity; } 

Agora podemos criar um shell MortarTower.Launche enviá-lo para a estrada.

  mortar.localRotation = Quaternion.LookRotation(new Vector3(dir.x, tanTheta, dir.y)); Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) ); 

Movimento de projéteis


Para Shellavançar, precisamos rastrear a duração de sua existência, ou seja, o tempo decorrido desde o disparo. Então podemos calcular sua posição em GameUpdate. Sempre fazemos isso com relação ao seu ponto de disparo, para que o projétil siga perfeitamente o caminho, independentemente da taxa de atualização.

  float age; … public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; transform.localPosition = p; return true; } 


Descasque.

Para alinhar as cascas com suas trajetórias, precisamos fazê-las olhar ao longo do vetor derivado, que é a velocidade delas no momento correspondente.

  public override bool GameUpdate () { … Vector3 d = launchVelocity; dy -= 9.81f * age; transform.localRotation = Quaternion.LookRotation(d); return true; } 


As conchas estão girando.

Limpamos o jogo


Agora que ficou claro que as conchas estão voando exatamente como deveriam, você pode remover as MortarTower.Launchtrajetórias da visualização.

  public void Launch (TargetPoint target) { … Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y) ); } 

Além disso, precisamos garantir que as conchas sejam destruídas após atingir o alvo. Como sempre apontamos para o chão, isso pode ser feito verificando Shell.GameUpdatese a posição vertical está abaixo de zero. Você pode fazer isso imediatamente após calculá-los, antes de mudar de posição e girar o projétil.

  public override bool GameUpdate () { age += Time.deltaTime; Vector3 p = launchPoint + launchVelocity * age; py -= 0.5f * 9.81f * age * age; if (py <= 0f) { OriginFactory.Reclaim(this); return false; } transform.localPosition = p; … } 

Detonação


Atiramos em conchas porque elas contêm explosivos. Quando o projétil atinge seu alvo, ele deve detonar e causar dano a todos os inimigos na área da explosão. O raio da explosão e o dano causado dependem do tipo de projétil disparado pela argamassa, portanto, adicionaremos MortarToweropções de configuração para eles.

  [SerializeField, Range(0.5f, 3f)] float shellBlastRadius = 1f; [SerializeField, Range(1f, 100f)] float shellDamage = 10f; 


Raio de explosão e 1.5 de dano de 15 projéteis.

Essa configuração é importante apenas durante a explosão, portanto deve ser adicionada Shelle seu método Initialize.

  float age, blastRadius, damage; public void Initialize ( Vector3 launchPoint, Vector3 targetPoint, Vector3 launchVelocity, float blastRadius, float damage ) { … this.blastRadius = blastRadius; this.damage = damage; } 

MortarTower só deve transmitir dados ao projétil após sua criação.

  Game.SpawnShell().Initialize( launchPoint, targetPoint, new Vector3(s * cosTheta * dir.x, s * sinTheta, s * cosTheta * dir.y), shellBlastRadius, shellDamage ); 

Para atirar em inimigos dentro do alcance, o projétil deve capturar alvos. Nós já temos o código para isso, mas é no Tower. Como é útil para tudo que precisa de um objetivo, copie sua funcionalidade TargetPointe a disponibilize estaticamente. Adicione um método para preencher o buffer, uma propriedade para obter a quantidade em buffer e um método para obter o destino em buffer.

  const int enemyLayerMask = 1 << 9; static Collider[] buffer = new Collider[100]; public static int BufferedCount { get; private set; } public static bool FillBuffer (Vector3 position, float range) { Vector3 top = position; top.y += 3f; BufferedCount = Physics.OverlapCapsuleNonAlloc( position, top, range, buffer, enemyLayerMask ); return BufferedCount > 0; } public static TargetPoint GetBuffered (int index) { var target = buffer[index].GetComponent<TargetPoint>(); Debug.Assert(target != null, "Targeted non-enemy!", buffer[0]); return target; } 

Agora podemos receber todos os alvos dentro do alcance até o tamanho máximo do buffer e causar danos ao detonar Shell.

  if (py <= 0f) { TargetPoint.FillBuffer(targetPoint, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy€.ApplyDamage(damage); } OriginFactory.Reclaim(this); return false; } 


Detonação de conchas.

Você também pode adicionar a uma TargetPointpropriedade estática para obter um destino aleatório do buffer.

  public static TargetPoint RandomBuffered => GetBuffered(Random.Range(0, BufferedCount)); 

Isso nos permitirá simplificar Tower, porque agora você pode usar para procurar um alvo aleatório TargetPoint.

 protected bool AcquireTarget (out TargetPoint target) { if (TargetPoint.FillBuffer(transform.localPosition, targetingRange)) { target = TargetPoint.RandomBuffered; return true; } target = null; return false; } 

Explosões


Tudo funciona, mas ainda não parece muito crível. Você pode melhorar a imagem adicionando a visualização da explosão quando a detonação do shell. Isso não apenas parecerá mais interessante, mas também fornecerá um feedback útil ao jogador. Para fazer isso, criaremos uma pré-fabricada da explosão como um raio laser. Somente será uma esfera mais transparente de cores brilhantes. Adicione um novo componente de entidade Explosioncom uma duração personalizada. Meio segundo será suficiente. Adicione a ela um método Initializeque defina a posição e o raio da explosão. Ao definir a escala, você precisa dobrar o raio, porque o raio da malha da esfera é 0,5. Também é um bom lugar para causar dano a todos os inimigos dentro do alcance, então também adicionaremos um parâmetro de dano. Além disso, ele precisa de um método GameUpdatepara verificar se o tempo está se esgotando.

 using UnityEngine; public class Explosion : WarEntity { [SerializeField, Range(0f, 1f)] float duration = 0.5f; float age; public void Initialize (Vector3 position, float blastRadius, float damage) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } transform.localPosition = position; transform.localScale = Vector3.one * (2f * blastRadius); } public override bool GameUpdate () { age += Time.deltaTime; if (age >= duration) { OriginFactory.Reclaim(this); return false; } return true; } } 

Adicione uma explosão a WarFactory.

  [SerializeField] Explosion explosionPrefab = default; [SerializeField] Shell shellPrefab = default; public Explosion Explosion€ => Get(explosionPrefab); public Shell Shell => Get(shellPrefab); 


Fábrica de guerra com uma explosão.

Adicione também ao Gamemétodo da fachada.

  public static Explosion SpawnExplosion () { Explosion explosion = instance.warFactory.Explosion€; instance.nonEnemies.Add(explosion); return explosion; } 

Agora ele Shellpode gerar e iniciar uma explosão ao atingir o alvo. A própria explosão causará danos.

  if (py <= 0f) { Game.SpawnExplosion().Initialize(targetPoint, blastRadius, damage); OriginFactory.Reclaim(this); return false; } 


Explosões de conchas.

Explosões mais suaves


Esferas imutáveis ​​em vez de explosões não parecem muito bonitas. Você pode melhorá-los animando opacidade e escala. Você pode usar uma fórmula simples para isso, mas vamos usar curvas de animação que são mais fáceis de configurar. Adicione para esses Explosiondois campos de configuração AnimationCurve. Usaremos as curvas para ajustar os valores ao longo da vida útil da explosão, e o tempo 1 indicará o fim da explosão, independentemente de sua duração real. O mesmo se aplica à escala e raio da explosão. Isso simplificará sua configuração.

  [SerializeField] AnimationCurve opacityCurve = default; [SerializeField] AnimationCurve scaleCurve = default; 

A opacidade começará e terminará com zero, suavemente dimensionada para um valor médio de 0,3. A escala começará em 0,7, aumentará rapidamente e depois se aproximará lentamente de 1.


Curvas de explosão.

Para definir a cor do material, usaremos o bloco de propriedades do material. onde preto é a variável de opacidade. A escala agora está definida como GameUpdate, mas precisamos rastrear usando o campo raio. Em Initializevocê pode usar a escala de duplicação. Os valores das curvas são encontrados chamando-os Evaluatecom um argumento, calculado como o tempo de vida atual da explosão, dividido pela duração da explosão.

  static int colorPropertyID = Shader.PropertyToID("_Color"); static MaterialPropertyBlock propertyBlock; … float scale; MeshRenderer meshRenderer; void Awake () { meshRenderer = GetComponent<MeshRenderer>(); Debug.Assert(meshRenderer != null, "Explosion without renderer!"); } public void Initialize (Vector3 position, float blastRadius, float damage) { … transform.localPosition = position; scale = 2f * blastRadius; } public override bool GameUpdate () { … if (propertyBlock == null) { propertyBlock = new MaterialPropertyBlock(); } float t = age / duration; Color c = Color.clear; ca = opacityCurve.Evaluate(t); propertyBlock.SetColor(colorPropertyID, c); meshRenderer.SetPropertyBlock(propertyBlock); transform.localScale = Vector3.one * (scale * scaleCurve.Evaluate(t)); return true; } 


Explosões animadas.

Conchas do marcador


Como as conchas são pequenas e possuem uma velocidade bastante alta, pode ser difícil perceber. E se você observar a captura de tela de um único quadro, as trajetórias são completamente incompreensíveis. Você pode torná-los mais óbvios adicionando um efeito de rastreamento às suas conchas. Para cascas convencionais, isso não é muito realista, mas podemos dizer que esses são marcadores. Essa munição é feita especialmente para que eles deixem uma marca brilhante, tornando suas trajetórias visíveis.

Existem diferentes maneiras de criar rastreamentos, mas você usará uma muito simples. Nós refazemos as explosões para Shellcriar uma pequena explosão em cada quadro. Essas explosões não causarão nenhum dano, portanto capturar alvos será um desperdício de recursos. Adicionar aExplosionsuporte para esse uso, fazendo com que o dano seja feito se for maior que zero e, em seguida, torne o parâmetro de dano Initializeopcional.

  public void Initialize ( Vector3 position, float blastRadius, float damage = 0f ) { if (damage > 0f) { TargetPoint.FillBuffer(position, blastRadius); for (int i = 0; i < TargetPoint.BufferedCount; i++) { TargetPoint.GetBuffered(i).Enemy.ApplyDamage(damage); } } transform.localPosition = position; radius = 2f * blastRadius; } 

Criaremos uma explosão no final Shell.GameUpdatecom um pequeno raio, por exemplo 0,1, para transformá-las em projéteis de rastreamento. Deve-se notar que, com essa abordagem, as explosões serão criadas quadro a quadro, ou seja, elas dependem da taxa de quadros, mas, para um efeito tão simples, isso é permitido.

  public override bool GameUpdate () { … Game.SpawnExplosion().Initialize(p, 0.1f); return true; } 

imagem

Rastreadores de projéteis. Artigo em PDF do

Repositório do Tutorial

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


All Articles