Creando Tower Defense en la Unidad: Balística

[ La primera , segunda y tercera parte del tutorial]

  • Soporte para diferentes tipos de torres.
  • Creando una torre de mortero.
  • Cálculo de trayectorias parabólicas.
  • Lanzamiento de proyectiles explosivos.

Esta es la cuarta parte de un tutorial sobre cómo crear un juego de defensa de torre simple. En él agregaremos torres de mortero disparando proyectiles detonantes en una colisión.

El tutorial fue creado en Unity 2018.4.4f1.


Los enemigos son bombardeados.

Tipos de torres


Un láser no es el único tipo de arma que se puede colocar en una torreta. En este tutorial agregaremos el segundo tipo de torres, que dispararán proyectiles que explotan al contacto, dañando a todos los enemigos cercanos. Para hacer esto, necesitamos soporte para varios tipos de torres.

Torre abstracta


La detección y el seguimiento de objetivos es una funcionalidad que cualquier torre puede usar, por lo que la ubicaremos en la clase base abstracta de torres. Para hacer esto, simplemente usamos la clase Tower , pero primero, duplicamos su contenido para su uso posterior en una clase LaserTower específica. Luego eliminamos todo el código relacionado con láser de Tower . Es posible que la torre no AcquireTarget un seguimiento de un objetivo específico, por lo tanto, elimine el campo target y cambie AcquireTarget y TrackTarget para que el parámetro de salida se use como parámetro de enlace. Luego, eliminaremos la visualización del OnDrawGizmosSelected de OnDrawGizmosSelected , pero dejaremos el rango de puntería, ya que se usa para todas las 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); } } 

Cambiemos la clase duplicada para que se convierta en una LaserTower que extienda la Tower y use la funcionalidad de su clase base, LaserTower el 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 () { … } } 

Luego actualice el prefabricado de la torre láser para usar el nuevo componente.


Componente de una torre láser.

Crear un tipo específico de torre


Para poder seleccionar qué torres se colocarán en el campo, agregaremos una enumeración TowerType similar a GameTileContentType . Crearemos soporte para la torre láser y la torre de mortero existentes, que crearemos más adelante.

 public enum TowerType { Laser, Mortar } 

Como crearemos una clase para cada tipo de torre, agregaremos una propiedad getter abstracta a Tower para indicar su tipo. Esto funciona de manera similar al tipo de comportamiento de una figura en la serie de tutoriales de Object Management .

  public abstract TowerType TowerType€ { get; } 

Redefinirlo en LaserTower para que devuelva el tipo correcto.

  public override TowerType TowerType€ => TowerType.Laser; 

A continuación, cambie GameTileContentFactory para que la fábrica pueda producir la torre del tipo deseado. Implementamos esto con una variedad de torres y agregamos un método Get público alternativo con el parámetro TowerType . Para verificar que la matriz está configurada correctamente, utilizaremos aserciones. Otro método de Get público ahora solo se aplicará al contenido de los mosaicos sin 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); } 

Sería lógico devolver el tipo más específico, por lo que idealmente, el tipo de retorno del nuevo método Get debería ser Tower . Pero el método privado Get utilizado para crear instancias de la prefabricación devuelve un GameTileContent . Aquí puede realizar la conversión o hacer que el método privado Get genérico. Elija la segunda opción.

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

Si bien solo tenemos una torre láser, la convertiremos en el único elemento del conjunto de torres de la fábrica.


Una serie de torres prefabricadas.

Crear instancias de tipos de torre específicos


Para crear una torre de un tipo específico, GameBoard.ToggleTower para que requiera el parámetro TowerType y lo TowerType a la 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); } } 

Esto crea una nueva oportunidad: el estado de la torre cambia cuando ya existe, pero las torres son de varios tipos. Hasta ahora, el cambio solo elimina la torre existente, pero sería lógico que se reemplazara por un nuevo tipo, así que implementemos esto. Como el mosaico permanece ocupado, no necesita buscar la ruta nuevamente.

  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 ahora debe rastrear el tipo de torre conmutable. Simplemente denotamos cada tipo de torre por un número. La torre láser es 1, será la torre predeterminada y la torre de mortero es 2. Al presionar las teclas numéricas, seleccionaremos el tipo apropiado de torres.

  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 mortero


Todavía no será posible colocar la torre de mortero, porque todavía no tiene una casa prefabricada. Comencemos creando un tipo MortarTower mínimo. Los morteros tienen una frecuencia de fuego para indicar cuál puede usar el campo de configuración "disparos por segundo". Además, necesitaremos un enlace al mortero para que pueda apuntar.

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

Ahora cree una casa prefabricada para la torre de mortero. Esto se puede hacer duplicando el prefabricado de la torre láser y reemplazando su componente de la torre. Luego nos deshacemos de los objetos de la torre y del rayo láser. Cambie el nombre de la turret a mortar , muévala hacia abajo de modo que quede sobre la base, dele un color gris claro y fíjela. Podemos dejar el colisionador de mortero, en este caso, usando un objeto separado, que es un colisionador simple superpuesto a la orientación predeterminada del mortero. Asigné un rango de mortero de 3.5 y una frecuencia de 1 disparo por segundo.

escena

jerarquía

inspector

Prefabricada de la torre de mortero.

¿Por qué se llaman morteros?
Las primeras variedades de esta arma fueron esencialmente cuencos de hierro, similares a los morteros, en los cuales los ingredientes se molieron usando una mano de mortero.

Agregue los morteros prefabricados a la matriz de fábrica para que las torres de mortero se puedan colocar en el campo. Sin embargo, todavía no están haciendo nada.

inspector

escena

Dos tipos de torres, una de ellas inactiva.

Cálculo de trayectoria


Mortira dispara un proyectil en ángulo, de modo que vuela sobre obstáculos y golpea el objetivo desde arriba. Por lo general, se utilizan proyectiles que detonan cuando chocan con o sobre un objetivo. Para no complicar las cosas, siempre apuntaremos al suelo para que los proyectiles exploten cuando su altura caiga a cero.

Puntería horizontal


Para apuntar el mortero, debemos apuntarlo horizontalmente hacia el objetivo, y luego cambiar su posición vertical para que el proyectil caiga a la distancia correcta. Comenzaremos con el primer paso. Primero, utilizaremos puntos relativos fijos, no objetivos móviles, para asegurarnos de que nuestros cálculos sean correctos.

Agregue un método MortarTower a GameUpdate , que siempre llama al método Launch . En lugar de disparar un proyectil real, visualizaremos cálculos matemáticos por ahora. El punto de disparo es la posición del mortero en el mundo, que se encuentra justo por encima del suelo. Colocamos el punto del objetivo a tres unidades a lo largo del eje X, y ponemos a cero el componente Y, porque siempre apuntamos al suelo. Luego mostraremos los puntos Debug.DrawLine línea amarilla entre ellos llamando a Debug.DrawLine . La línea será visible en el modo de escena para un cuadro, pero esto es suficiente, porque en cada cuadro dibujamos una nueva línea.

  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); } 


Apuntamos a un punto fijo relativo a la torre.

Usando esta línea podemos definir un triángulo rectángulo. Su punto superior está en la posición de mortero. En cuanto a los morteros, esto es  beginbmatrix00 endbmatrix . El punto a continuación, en la base de la torre, es  beginbmatrix0y endbmatrix , y el punto en el objetivo es  beginbmatrixxy endbmatrix donde x igual a 3 y y Es la posición vertical negativa del mortero. Necesitamos rastrear estos dos valores.

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


Triángulo apuntando.

En general, el objetivo puede estar en cualquier lugar dentro del alcance de la torre, por lo que Z también debe tenerse en cuenta. Sin embargo, el triángulo de puntería sigue siendo bidimensional, simplemente gira alrededor del eje Y. Para ilustrar esto, agregaremos el parámetro del vector de desplazamiento relativo en Launch y lo llamaremos con cuatro desplazamientos en XZ:  beginbmatrix30 endbmatrix ,  beginbmatrix01 endbmatrix ,  beginbmatrix11 endbmatrix y  beginbmatrix31 endbmatrix . Cuando el punto de puntería se vuelve igual al punto del disparo más este desplazamiento, y luego su coordenada Y se vuelve igual a cero.

  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; … } 

Ahora x del triángulo de puntería es igual a la longitud del vector 2D que apunta desde la base de la torre al punto de puntería. Al normalizar este vector, también obtenemos el vector de dirección XZ, que puede usarse para alinear el triángulo. Puede mostrarlo dibujando la parte inferior del triángulo como una línea blanca obtenida de la dirección yx.

  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 ); 


Alineado apuntando triángulos.

Ángulo de disparo


A continuación, debemos averiguar el ángulo en el que disparar el proyectil. Es necesario derivarlo de la física de la trayectoria del proyectil. No tomaremos en cuenta la resistencia, el viento y otros obstáculos, solo la velocidad del disparo. v y la gravedad g=9.81 .

Offset d El proyectil está en línea con el triángulo de puntería y puede ser descrito por dos componentes. Con desplazamiento horizontal, es simple: es dx=vxt donde t - Tiempo después del disparo. Con el componente vertical todo es similar, entonces está sujeto a una aceleración negativa debido a la gravedad, por lo tanto tiene la forma dy=vyt(gt2)/2 .

¿Cómo se realiza el cálculo de compensación?
Velocidad v determinado por la distancia por segundo, por lo tanto, multiplicando la velocidad por la duración t tenemos la distancia d=vt . Cuando la aceleración está involucrada a , la velocidad es variable. La aceleración es el cambio en la velocidad por segundo, es decir, la distancia por segundo al cuadrado. En cualquier momento, la velocidad es v=en . En nuestro caso, hay una aceleración constante. a=g , por lo que podemos dividirlo por la mitad para obtener la velocidad promedio y multiplicarlo por tiempo para encontrar el desplazamiento d=(en2)/2 causado por la gravedad.

Disparamos proyectiles a la misma velocidad s que no depende del ángulo de disparo  theta (theta) Eso es vx=s cos theta y vy=s sin theta .


Cálculo de la velocidad de un disparo.

Realizando la sustitución, obtenemos dx=st cos theta y dy=st sin theta(gt2)/2 .

El proyectil se dispara para que su tiempo de vuelo t es el valor exacto necesario para lograr el objetivo. Como es más fácil trabajar con desplazamiento horizontal, podemos expresar el tiempo como t=dx/vx . En el punto final dx=x eso es t=x/(s cos theta) . Esto significa que y=x tan theta(gx2)/(2s2 cos2 theta) .

¿Cómo obtener la ecuación y?
y=dy=s(x/(s cos theta)) sin theta(g(x/(s cos theta))2)/2=x sin theta/ cos theta(gx2)/(2s2 cos2 theta) y  tan theta= sin theta/ cos theta .

Usando esta ecuación encontramos  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) .
¿Cómo obtener la ecuación tan θ?
Primero usaremos la identidad trigonométrica  sec theta=1/ cos theta y 1+ tan2 theta= sec2 theta venir a y=x tan theta(gx2)/(2s2)(1+ tan2 theta)=(gx2)/(2s2) tan2 theta+x tan theta(gx2)/(2s2) .

Esta es una expresión de la forma au2+bu+c=0 donde u= tan theta , a=(gx2)/(2s2) , b=x y c=ay .

Podemos resolverlo usando la fórmula de raíces de la ecuación cuadrática u=(b+ sqrt(b24ac))/(2a) .

Después de su sustitución, la ecuación se volverá confusa, pero puede simplificarla multiplicando por m=s2/x para obtener  tan theta=(mb+m sqrtr)/(2ma) donde r=b24ac .

En este caso, obtenemos  tan theta=(s2+ sqrt(m2r))/(gx) .

Como resultado m2r=(s4/x2)r=s4+2gs2c=s4g2x22gys2=s4g(gx2+2ys2) .

Hay dos ángulos posibles, porque puedes apuntar alto o bajo. Una trayectoria baja es más rápida porque está más cerca de una línea recta al objetivo. Pero la alta trayectoria parece más interesante, así que la elegiremos. Esto significa que solo necesitamos usar la solución más grande.  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) . Lo calculamos y también  cos theta con  sin theta , porque los necesitamos para obtener el vector de velocidad del disparo. Para esto necesitas convertir  tan theta al ángulo en radianes usando Mathf.Atan . Primero, usemos una velocidad de disparo constante 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; 

Visualicemos la trayectoria dibujando diez segmentos azules que muestren el primer segundo de vuelo.

  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; } 


Parabola rutas de vuelo que duran un segundo.

Los dos puntos más lejanos se pueden alcanzar en menos de un segundo, por lo que vemos sus trayectorias completas, y los segmentos continúan un poco más bajo tierra. Para los otros dos puntos, se necesitan ángulos de disparo más grandes, debido a que las trayectorias se vuelven más largas y el vuelo dura más de un segundo.

Velocidad de disparo


Si desea alcanzar los dos puntos más cercanos en un segundo, debe reducir la velocidad del disparo. Hagámoslo igual a 4.

  float s = 4f; 


Velocidad de disparo reducida a 4.

Sus trayectorias ahora están completas, pero las otras dos se han ido. Esto sucedió porque la velocidad del disparo ahora no es suficiente para alcanzar estos puntos. En tales casos, soluciones a  tan theta no, es decir, obtenemos la raíz cuadrada de un número negativo, lo que lleva a valores de NaN y la desaparición de líneas. Podemos reconocer esto marcando r a la negatividad

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

Esta situación se puede evitar estableciendo una velocidad de disparo suficientemente alta. Pero si es demasiado grande, entonces alcanzar objetivos cerca de la torre requerirá trayectorias muy altas y un largo tiempo de vuelo, por lo que debe dejar la velocidad lo más baja posible. La velocidad de disparo debe ser suficiente para alcanzar el objetivo al alcance máximo.

En el rango máximo r=0 es decir para  tan theta Solo hay una solución, que corresponde a una trayectoria baja. Esto significa que sabemos la velocidad requerida del disparo. s = s q r t ( g ( y + s q r t ( x 2 + y 2 ) ) )   .

¿Cómo derivar esta ecuación para s?
Necesito decidir s4g(gx2+2ys2)=s42gys2g2x2=0 para s .

Esta es una expresión de la forma au2+bu+c=0 donde u=s2 , a=1 , b=2gy y c=g2x2 .

Puedes resolverlo usando la fórmula simplificada de las raíces de la ecuación cuadrática u=(b+ sqrt(b24c))/2 .

Después de la sustitución obtenemos s2=(2gy+ sqrt(4g2y2+4g2x2))/2=gy+g sqrt(x2+y2) .

Necesitamos una solución positiva, así que llegamos a s2=g(y+ sqrt(x2+y2)) .

Necesitamos determinar la velocidad requerida solo cuando los morteros se despiertan (Despertar) o cuando cambiamos su alcance en el modo Jugar. Por lo tanto, lo rastrearemos usando el campo y lo calcularemos en Awake y 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))); } 

Sin embargo, debido a las limitaciones en la precisión de los cálculos de coma flotante, determinar el objetivo muy cerca del rango máximo puede ser erróneo. Por lo tanto, al calcular la velocidad requerida, agregamos una pequeña cantidad al rango. Además, el radio del colisionador del enemigo esencialmente expande el radio máximo del alcance de la torre. Lo hicimos igual a 0.125, pero con un aumento en la escala del enemigo, puede duplicarse tanto como sea posible, por lo que aumentaremos el rango real en aproximadamente 0.25, por ejemplo, en 0.25001.

  float x = targetingRange + 0.25001f; 

A continuación, aplique la ecuación derivada para la velocidad de un disparo en Launch .

  float s = launchSpeed; 


Aplicar la velocidad calculada al rango de puntería 3.5.

Disparando


Teniendo el cálculo correcto de la trayectoria, puede deshacerse de los objetivos de prueba relativos. Ahora debe pasar el punto de Launch al objetivo.

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

Además, los disparos no se disparan en cada cuadro. Necesitamos rastrear el proceso del disparo de la misma manera que el proceso de crear enemigos y capturar un objetivo aleatorio cuando llegue el momento del disparo en GameUpdate . Pero en este punto, puede que no haya metas disponibles. En este caso, continuamos el proceso de disparo, pero sin más acumulación. Para evitar un bucle infinito, debe hacerlo un poco menos de 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; } } } 

No rastreamos objetivos entre disparos, pero necesitamos rotar correctamente el mortero durante los disparos. Puede usar la dirección horizontal del disparo para rotar el mortero horizontalmente con Quaternion.LookRotation . También necesitamos con  t a n t h e t a  aplique el ángulo de disparo para el componente Y del vector de dirección. Esto funcionará porque la dirección horizontal tiene una longitud de 1, es decir  t a n t h e t a = s i n t h e t a    .


Descomposición del vector de giro de la mirada.

  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 ver aún la trayectoria de los disparos, puede agregar un parámetro a Debug.DrawLine que permita Debug.DrawLine durante mucho tiempo.

  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 ); 


Apuntando

Conchas


El significado de calcular trayectorias es que ahora sabemos cómo disparar proyectiles. Luego tenemos que crearlos y dispararles.

Fábrica de guerra


Necesitamos una fábrica para instanciar objetos de shell. Mientras están en el aire, los proyectiles existen solos y ya no dependen de los morteros que los dispararon. Por lo tanto, no deben ser procesados ​​por la torre de mortero, y la fábrica de contenido de baldosas tampoco es adecuada para esto.Creemos crear para todo lo relacionado con las armas, una nueva fábrica y llamémosla fábrica de guerra. Primero, cree un resumen WarEntitycon una propiedad OriginFactoryy un 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); } } 

Luego cree una entidad específica Shellpara los shells.

 using UnityEngine; public class Shell : WarEntity { } 

Luego cree el WarFactoryque creará el proyectil usando la propiedad 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); } } 

Crea un prefabricado para el proyectil. Usé un cubo simple con la misma escala de 0.25 y material oscuro, así como un componente Shell. Luego cree el activo de fábrica y asígnele la prefabricación del proyectil.


Fábrica de guerra.

Comportamiento del juego


Para mover los proyectiles necesitan ser actualizados. Puedes usar el mismo enfoque que se usa Gamepara actualizar el estado de los enemigos. De hecho, incluso podemos generalizar este enfoque creando un componente abstracto GameBehaviorque amplíe MonoBehavioury agregue un método virtual GameUpdate.

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

Ahora haga la refactorización EnemyCollection, convirtiéndola en 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; } } } } 

Hagamos que se WarEntityexpanda GameBehavior, no MonoBehavior.

 public abstract class WarEntity : GameBehavior { … } 

Haremos lo mismo Enemy, esta vez anulando el método GameUpdate.

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

A partir de ahora, Gametendrá que rastrear dos colecciones, una para enemigos y otra para no enemigos. Los no enemigos deben actualizarse después de todo lo demás.

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

El último paso para implementar una actualización de shell es agregarlos a una colección de no enemigos. Hagamos esto con una función Gameque será una fachada estática para una fábrica de guerra para que los proyectiles puedan ser creados por un desafío Game.SpawnShell(). Para que esto funcione, Gamedebe tener un enlace a war factory y realizar un seguimiento de su propia instancia.

  [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; } 


Juego con war factory.

¿Es una fachada estática una buena solución?
, , .

Disparamos un proyectil


Después de crear una instancia del proyectil, debe volar a lo largo de su camino hasta que alcance el objetivo final. Para hacer esto, agregue al Shellmétodo Initializey úselo para especificar el punto del disparo, el punto del objetivo y la velocidad del disparo.

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

Ahora podemos crear un shell MortarTower.Launchy enviarlo en el camino.

  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) ); 

Movimiento de proyectiles


Para Shellmovernos, necesitamos rastrear la duración de su existencia, es decir, el tiempo transcurrido desde el disparo. Entonces podemos calcular su posición en GameUpdate. Siempre hacemos esto con respecto a su punto de disparo, para que el proyectil siga perfectamente el camino, independientemente de la frecuencia de actualización.

  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; } 


Bombardeo

Para alinear las capas con sus trayectorias, debemos hacer que miren a lo largo del vector derivado, que es su velocidad en el momento correspondiente.

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


Las conchas están girando.

Limpiamos el juego


Ahora que está claro que los proyectiles están volando exactamente como deberían, puede eliminar las MortarTower.Launchtrayectorias de la visualización.

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

Además, debemos asegurarnos de que los proyectiles se destruyan después de alcanzar el objetivo. Como siempre apuntamos al suelo, esto se puede hacer verificando Shell.GameUpdatesi la posición vertical está por debajo de cero. Puede hacer esto inmediatamente después de calcularlos, antes de cambiar la posición y girar el proyectil.

  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; … } 

Detonación


Disparamos proyectiles porque contienen explosivos. Cuando el proyectil alcanza su objetivo, debe detonar e infligir daño a todos los enemigos en el área de la explosión. El radio de la explosión y el daño infligido dependen del tipo de proyectiles disparados por el mortero, por lo que agregaremos MortarToweropciones de configuración para ellos.

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


Radio de explosión y 1.5 daños de 15 proyectiles.

Esta configuración es importante solo durante la explosión, por lo que debe agregarse Shelly su 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 solo debe transmitir datos al proyectil después de su creación.

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

Para disparar a los enemigos dentro del alcance, el proyectil debe capturar objetivos. Ya tenemos código para esto, pero está dentro Tower. Dado que es útil para todo lo que necesita un objetivo, copie su funcionalidad TargetPointy hágala estáticamente disponible. Agregue un método para llenar el búfer, una propiedad para obtener la cantidad almacenada y un método para obtener el objetivo almacenado.

  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; } 

Ahora podemos recibir todos los objetivos dentro del alcance hasta el tamaño máximo de búfer e infligir daño tras la detonación 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; } 


Detonación de conchas.

También puede agregar a una TargetPointpropiedad estática para obtener un objetivo aleatorio del búfer.

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

Esto nos permitirá simplificar Tower, porque ahora puede usar para buscar un objetivo aleatorio TargetPoint.

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

Explosiones


Todo funciona, pero todavía no parece muy creíble. Puede mejorar la imagen agregando la visualización de la explosión cuando la detonación del caparazón. Esto no solo se verá más interesante, sino que también le dará al jugador comentarios útiles. Para hacer esto, crearemos un prefabricado de la explosión como un rayo láser. Solo será una esfera más transparente de color brillante. Agregue un nuevo componente de entidad Explosioncon una duración personalizada. Medio segundo será suficiente. Agrégale un método Initializeque establezca la posición y el radio de la explosión. Al configurar la escala, debe duplicar el radio, porque el radio de la malla de la esfera es 0.5. También es un buen lugar para hacer daño a todos los enemigos dentro del alcance, por lo que también agregaremos un parámetro de daño. Además, necesita un método GameUpdatepara verificar si el tiempo se acaba.

 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; } } 

Añadir una explosión a WarFactory.

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


Fábrica de guerra con una explosión.

También agregue al Gamemétodo de fachada.

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

Ahora Shellpuede generar e iniciar una explosión al alcanzar el objetivo. La explosión en sí misma causará daños.

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


Explosiones de conchas.

Explosiones más suaves


Las esferas inmutables en lugar de las explosiones no se ven muy hermosas. Puedes mejorarlos animando la opacidad y la escala. Puede usar una fórmula simple para esto, pero usemos curvas de animación que sean más fáciles de configurar. Agregue para estos Explosiondos campos de configuración AnimationCurve. Usaremos las curvas para ajustar los valores durante la vida útil de la explosión, y el tiempo 1 indicará el final de la explosión, independientemente de su duración real. Lo mismo se aplica a la escala y el radio de la explosión. Esto simplificará su configuración.

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

La opacidad comenzará y terminará con cero, escalada suavemente a un valor promedio de 0.3. La escala comenzará en 0.7, aumentará rápidamente y luego se acercará lentamente a 1.


Curvas de explosión.

Para establecer el color del material, utilizaremos el bloque de propiedades del material. donde negro es la variable de opacidad. La escala ahora está configurada en GameUpdate, pero necesitamos rastrear usando el campo de radio. En Initializeusted puede usar la escala de duplicación. Los valores de las curvas se encuentran al llamarlos Evaluatecon un argumento, calculado como la vida útil actual de la explosión, dividido por la duración de la explosión.

  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; } 


Explosiones animadas.

Conchas trazadoras


Dado que los depósitos son pequeños y tienen una velocidad bastante alta, pueden ser difíciles de notar. Y si nos fijamos en la captura de pantalla de un solo cuadro, las trayectorias son completamente incomprensibles. Puede hacerlos más obvios agregando un efecto de rastreo a sus proyectiles. Para los proyectiles convencionales, esto no es muy realista, pero podemos decir que estos son trazadores. Dicha munición está hecha especialmente para que dejen una marca brillante, haciendo visibles sus trayectorias.

Existen diferentes formas de crear trazas, pero utilizará una muy simple. Rehacemos las explosiones para Shellcrear una pequeña explosión en cada cuadro. Estas explosiones no causarán ningún daño, por lo que capturar objetivos será un desperdicio de recursos. Añadir aExplosionsoporte para este uso haciendo que el daño se haga si es mayor que cero, y luego haga que el parámetro de daño sea 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; } 

Crearemos una explosión al final Shell.GameUpdatecon un radio pequeño, por ejemplo 0.1, para convertirlos en proyectiles trazadores. Cabe señalar que con este enfoque, se crearán explosiones cuadro por cuadro, es decir, dependen de la velocidad de cuadros, pero para un efecto tan simple esto es permisible.

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

imagen

Rastreadores de proyectiles.

Tutorial Repositorio
PDF Artículo

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


All Articles