[
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.
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.
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(s4−g(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=a−y .
Podemos resolverlo usando la fórmula de raíces de la ecuación cuadrática u=(−b+− sqrt(b2−4ac))/(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=b2−4ac .
En este caso, obtenemos tan theta=(s2+− sqrt(m2r))/(gx) .
Como resultado m2r=(s4/x2)r=s4+2gs2c=s4−g2x2−2gys2=s4−g(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(s4−g(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 s4−g(gx2+2ys2)=s4−2gys2−g2x2=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(b2−4c))/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 );
ApuntandoConchas
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 WarEntity
con una propiedad OriginFactory
y 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 Shell
para los shells. using UnityEngine; public class Shell : WarEntity { }
Luego cree el WarFactory
que 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 Game
para actualizar el estado de los enemigos. De hecho, incluso podemos generalizar este enfoque creando un componente abstracto GameBehavior
que amplíe MonoBehaviour
y 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 WarEntity
expanda 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, Game
tendrá 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 Game
que 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, Game
debe 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 Shell
método Initialize
y ú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.Launch
y 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 Shell
movernos, 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; }
BombardeoPara 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.Launch
trayectorias 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.GameUpdate
si 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 MortarTower
opciones 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 Shell
y 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 TargetPoint
y 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 TargetPoint
propiedad 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 Explosion
con una duración personalizada. Medio segundo será suficiente. Agrégale un método Initialize
que 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 GameUpdate
para 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 Game
método de fachada. public static Explosion SpawnExplosion () { Explosion explosion = instance.warFactory.Explosion€; instance.nonEnemies.Add(explosion); return explosion; }
Ahora Shell
puede 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 Explosion
dos 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 Initialize
usted puede usar la escala de duplicación. Los valores de las curvas se encuentran al llamarlos Evaluate
con 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 Shell
crear 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 aExplosion
soporte 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 Initialize
opcional. 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.GameUpdate
con 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; }
Rastreadores de proyectiles.Tutorial RepositorioPDF Artículo