Création de Tower Defense dans Unity: balistique

[ Les première , deuxième et troisième parties du tutoriel]

  • Prise en charge de différents types de tours.
  • Création d'une tour de mortier.
  • Calcul des trajectoires paraboliques.
  • Lancement d'explosions d'obus.

Ceci est la quatrième partie d'un tutoriel sur la création d'un jeu de tower defense simple. Nous y ajouterons des tours de mortier tirant des obus détonants lors d'une collision.

Le didacticiel a été créé dans Unity 2018.4.4f1.


Les ennemis sont bombardés.

Types de tours


Un laser n'est pas le seul type d'arme pouvant être placé sur une tourelle. Dans ce tutoriel, nous ajouterons le deuxième type de tours, qui tirera des obus explosant au contact, endommageant tous les ennemis à proximité. Pour ce faire, nous avons besoin d'un support pour différents types de tours.

Tour abstraite


La détection et le suivi des cibles sont une fonctionnalité que toute tour peut utiliser, nous allons donc la mettre dans la classe de base abstraite des tours. Pour ce faire, nous utilisons simplement la classe Tower , mais tout d'abord, dupliquez son contenu pour une utilisation ultérieure dans une classe LaserTower spécifique. Ensuite, nous supprimons tout le code lié au laser de Tower . La tour peut ne pas suivre une cible spécifique, supprimez donc le champ target et modifiez AcquireTarget et TrackTarget afin que le paramètre de sortie soit utilisé comme paramètre de liaison. Ensuite, nous supprimerons la visualisation OnDrawGizmosSelected d' OnDrawGizmosSelected , mais nous quitterons la plage de visée, car elle est utilisée pour toutes les tours.

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

LaserTower la classe en double afin qu'elle se transforme en une LaserTower qui étend la Tower et utilise les fonctionnalités de sa classe de base, en supprimant le code en double.

 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 () { … } } 

Mettez ensuite à jour le préfabriqué de la tour laser pour utiliser le nouveau composant.


Composant d'une tour laser.

Création d'un type spécifique de tour


Pour pouvoir sélectionner les tours à placer sur le terrain, nous ajouterons une énumération TowerType similaire à GameTileContentType . Nous créerons un support pour la tour laser et la tour de mortier existantes, que nous créerons plus tard.

 public enum TowerType { Laser, Mortar } 

Puisque nous créerons une classe pour chaque type de tour, nous ajouterons une propriété getter abstraite à Tower pour indiquer son type. Cela fonctionne de manière similaire au type de comportement d'une figure dans la série de didacticiels de gestion d'objets .

  public abstract TowerType TowerType€ { get; } 

Redéfinissez-le dans LaserTower afin qu'il renvoie le type correct.

  public override TowerType TowerType€ => TowerType.Laser; 

Ensuite, modifiez la GameTileContentFactory afin que l'usine puisse produire la tour du type souhaité. Nous implémentons cela avec un tableau de tours et ajoutons une méthode Get publique alternative avec le paramètre TowerType . Pour vérifier que la baie est correctement configurée, nous utiliserons des assertions. Une autre méthode publique Get ne s'applique désormais qu'au contenu des tuiles sans tours.

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

Il serait logique de renvoyer le type le plus spécifique, donc idéalement, le type de retour de la nouvelle méthode Get devrait être Tower . Mais la méthode Get privée utilisée pour instancier le préfabriqué renvoie un GameTileContent . Ici, vous pouvez soit effectuer la conversion, soit rendre générique la méthode Get privée. Choisissons la deuxième option.

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

Bien que nous ne disposions que d'une tour laser, nous en ferons le seul élément du réseau de tours de l'usine.


Une gamme de tours préfabriquées.

Création d'instances de types de tour spécifiques


Pour créer une tour d'un type spécifique, nous GameBoard.ToggleTower afin qu'il nécessite le paramètre TowerType et le TowerType à l'usine.

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

Cela crée une nouvelle opportunité: l'état de la tour change alors qu'il existe déjà, mais les tours sont de différents types. Jusqu'à présent, la commutation supprime simplement la tour existante, mais il serait logique qu'elle soit remplacée par un nouveau type, alors implémentons cela. Étant donné que la tuile reste occupée, vous n'avez pas besoin de rechercher à nouveau le chemin.

  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 devrait maintenant suivre le type de tour commutable. Nous désignons simplement chaque type de tour par un nombre. La tour laser est 1, ce sera la tour par défaut, et la tour de mortier est 2. En appuyant sur les touches numériques, nous sélectionnerons le type de tour approprié.

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

Tour de mortier


Il ne sera pas encore possible de placer la tour de mortier, car elle n'a pas encore de préfabriqué. Commençons par créer un type MortarTower minimal. Les mortiers ont une fréquence de tir, pour indiquer laquelle vous pouvez utiliser le champ de configuration «tirs par seconde». De plus, nous aurons besoin d'un lien vers le mortier pour qu'il puisse viser.

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

Créez maintenant un préfabriqué pour la tour de mortier. Cela peut être fait en dupliquant le préfabriqué de la tour laser et en remplaçant son composant de tour. Ensuite, nous nous débarrassons des objets de la tour et du faisceau laser. Renommez la turret en mortar , déplacez-la vers le bas pour qu'elle repose sur le dessus de la base, donnez-lui une couleur gris clair et fixez-la. Nous pouvons laisser le collisionneur de mortier, dans ce cas, en utilisant un objet séparé, qui est un simple collisionneur superposé à l'orientation par défaut du mortier. J'ai assigné une plage de mortier de 3,5 et une fréquence de 1 tir par seconde.

scène

hiérarchie

inspecteur

Préfabriqué de la tour de mortier.

Pourquoi sont-ils appelés mortiers?
Les premières variétés de cette arme étaient essentiellement des bols en fer, similaires aux mortiers, dans lesquels les ingrédients étaient broyés à l'aide d'un pilon.

Ajoutez les mortiers préfabriqués au réseau d'usine afin que les tours de mortier puissent être placées sur le terrain. Cependant, ils ne font encore rien.

inspecteur

scène

Deux types de tours, dont une inactive

Calcul de trajectoire


Mortira tire un obus à un angle, de sorte qu'il survole les obstacles et frappe la cible par le haut. En règle générale, des obus sont utilisés qui explosent lorsqu'ils entrent en collision avec ou au-dessus d'une cible. Afin de ne pas compliquer les choses, nous viserons toujours le sol pour que les obus explosent lorsque leur hauteur tombe à zéro.

Visée horizontale


Pour viser le mortier, nous devons le pointer horizontalement vers la cible, puis changer sa position verticale pour que le projectile atterrisse à la bonne distance. Nous allons commencer par la première étape. Premièrement, nous utiliserons des points relatifs fixes, pas des cibles mobiles, pour nous assurer que nos calculs sont corrects.

Ajoutez une méthode MortarTower à GameUpdate , qui appelle toujours la méthode Launch . Au lieu de tirer un vrai projectile, nous allons visualiser les calculs mathématiques pour l'instant. Le point de tir est la position du mortier dans le monde, qui est situé juste au-dessus du sol. Nous plaçons le point de la cible à trois unités de celui-ci le long de l'axe X et mettons à zéro la composante Y, car nous visons toujours le sol. Ensuite, nous afficherons les points en Debug.DrawLine ligne jaune entre eux en appelant Debug.DrawLine . La ligne sera visible en mode scène pour une image, mais cela suffit, car dans chaque image, nous dessinons une nouvelle ligne.

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


Nous visons un point fixe par rapport à la tour.

En utilisant cette ligne, nous pouvons définir un triangle rectangle. Son point supérieur est en position mortier. En ce qui concerne les mortiers, c'est  b e g i n b m a t r i x 00 e n d b m a t r i x  . Le point ci-dessous, à la base de la tour, est  b e g i n b m a t r i x 0y e n d b m a t r i x  et le but de l'objectif est  b e g i n b m a t r i x xy e n d b m a t r i x x égal à 3, et y Est la position verticale négative du mortier. Nous devons suivre ces deux valeurs.

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


Triangle visant.

En général, la cible peut être n'importe où dans la portée de la tour, donc Z doit également être pris en compte. Cependant, le triangle de visée reste toujours bidimensionnel, il tourne simplement autour de l'axe Y. Pour illustrer cela, nous ajouterons le paramètre du vecteur de déplacement relatif dans Launch et l'appellerons avec quatre déplacements dans 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 et  beginbmatrix31 endbmatrix . Lorsque le point de visée devient égal au point de la prise de vue plus ce décalage, puis sa coordonnée Y devient égale à zéro.

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

Maintenant, x du triangle de visée est égal à la longueur du vecteur 2D pointant de la base de la tour au point de visée. En normalisant ce vecteur, nous obtenons également le vecteur de direction XZ, qui peut être utilisé pour aligner le triangle. Vous pouvez le montrer en traçant le bas du triangle comme une ligne blanche obtenue à partir de la direction et de x.

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


Triangles de visée alignés.

Angle de tir


Ensuite, nous devons trouver l'angle sous lequel tirer le projectile. Il faut le dériver de la physique de la trajectoire du projectile. Nous ne prendrons pas en compte la traînée, le vent et les autres obstacles, uniquement la vitesse du tir v et la gravité g=9,81 .

Décalage d le projectile est en ligne avec le triangle de visée et peut être décrit par deux composantes. Avec le déplacement horizontal, c'est simple: c'est dx=vxtt - le temps après le tir. Avec la composante verticale, tout est similaire, alors il est soumis à une accélération négative due à la gravité, il a donc la forme dy=vyt(gt2)/2 .

Comment s'effectue le calcul du décalage?
La vitesse v déterminé par la distance par seconde, donc, multipliant la vitesse par la durée t nous obtenons la distance d = v t . Lorsque l'accélération est impliquée un , la vitesse est variable. L'accélération est le changement de vitesse par seconde, c'est-à-dire la distance par seconde au carré. A tout moment, la vitesse est v=àà . Dans notre cas, il y a une accélération constante a=g , nous pouvons donc le diviser en deux pour obtenir la vitesse moyenne, et multiplier par le temps pour trouver le décalage d=(à2)/2à causée par la gravité.

Nous tirons des obus à la même vitesse s qui ne dépend pas de l'angle de prise de vue  theta (thêta). C’est vx=s cos theta et vy=s sin theta .


Calcul de la vitesse d'un tir.

En effectuant la substitution, nous obtenons dx=st cos theta et dy=st sin theta(gt2)/2 .

Le projectile est tiré de sorte que son temps de vol t est la valeur exacte nécessaire pour atteindre l'objectif. Puisqu'il est plus facile de travailler avec un déplacement horizontal, nous pouvons exprimer le temps comme t=dx/vx . Au point final dx=x c'est t=x/(s cos theta) . Cela signifie que y=x tan theta(gx2)/(2s2 cos2 theta) .

Comment obtenir l'équation 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) et  tan theta= sin theta/ cos theta .

En utilisant cette équation, nous trouvons  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) .
Comment obtenir l'équation tan θ?
Nous allons d'abord utiliser l'identité trigonométrique  sec theta=1/ cos theta et 1+ tan2 theta= sec2 theta venir à y=x tan theta(gx2)/(2s2)(1+ tan2 theta)=(gx2)/(2s2) tan2 theta+x tan theta(gx2)/(2s2) .

Ceci est une expression de la forme au2+bu+c=0u= tan theta , a=(gx2)/(2s2) , b=x et c=ay .

Nous pouvons le résoudre en utilisant la formule des racines de l'équation quadratique u=(b+ sqrt(b24ac))/(2a) .

Après sa substitution, l'équation deviendra déroutante, mais vous pouvez la simplifier en multipliant par m=s2/x donc pour obtenir  tan theta=(mb+m sqrtr)/(2ma)r=b24ac .

Dans ce cas, on obtient  tan theta=(s2+ sqrt(m2r))/(gx) .

En conséquence m2r=(s4/x2)r=s4+2gs2c=s4g2x22gys2=s4g(gx2+2ys2) .

Il y a deux angles possibles, car vous pouvez viser haut ou bas. Une trajectoire basse est plus rapide car elle est plus proche d'une ligne droite vers la cible. Mais la trajectoire élevée semble plus intéressante, nous allons donc la choisir. Cela signifie que nous n'avons besoin que de la plus grande solution.  tan theta=(s2+ sqrt(s4g(gx2+2ys2)))/(gx) . Nous le calculons, et aussi  cos theta avec  sin theta , car nous en avons besoin pour obtenir le vecteur vitesse du plan. Pour cela, vous devez convertir  tan theta à l'angle radian en utilisant Mathf.Atan . Tout d'abord, utilisons une vitesse de tir 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; 

Visualisons la trajectoire en traçant dix segments bleus montrant la première seconde de vol.

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


Trajectoires de parabole d'une durée d'une seconde.

Les deux points les plus éloignés peuvent être atteints en moins d'une seconde, nous voyons donc leurs trajectoires entières, et les segments continuent un peu plus loin sous terre. Pour les deux autres points, des angles de prise de vue plus importants sont nécessaires, en raison desquels les trajectoires s'allongent et le vol dure plus d'une seconde.

Vitesse de tir


Si vous souhaitez atteindre les deux points les plus proches en une seconde, vous devez réduire la vitesse du tir. Faisons-le égal à 4.

  float s = 4f; 


Vitesse de tir réduite à 4.

Leurs trajectoires sont maintenant terminées, mais les deux autres ont disparu. Cela s'est produit parce que la vitesse du tir n'est plus suffisante pour atteindre ces points. Dans de tels cas, des solutions  tan theta non, c'est-à-dire que nous obtenons la racine carrée d'un nombre négatif, ce qui entraîne des valeurs de NaN et la disparition des lignes. Nous pouvons le reconnaître en vérifiant r à la négativité.

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

Cette situation peut être évitée en définissant une vitesse de prise de vue suffisamment élevée. Mais s'il est trop grand, pour atteindre des cibles près de la tour, il faudra des trajectoires très élevées et un long temps de vol, vous devez donc laisser la vitesse aussi basse que possible. La vitesse de tir doit être suffisante pour atteindre la cible à portée maximale.

À portée maximale r=0 , c'est-à-dire pour  tan theta il n'y a qu'une seule solution, correspondant à un chemin bas. Cela signifie que nous connaissons la vitesse requise du tir. s= sqrt(g(y+ sqrt(x2+y2))) .

Comment dériver cette équation pour s?
Besoin de décider s4g(gx2+2ys2)=s42gys2g2x2=0 pour s .

Ceci est une expression de la forme au2+bu+c=0u=s2 , a=1 , b=2gy et c=g2x2 .

Vous pouvez le résoudre en utilisant la formule simplifiée des racines de l'équation quadratique u=(b+ sqrt(b24c))/2 .

Après substitution, nous obtenons s2=(2gy+ sqrt(4g2y2+4g2x2))/2=gy+g sqrt(x2+y2) .

Nous avons besoin d'une solution positive, nous arrivons donc à s2=g(y+ sqrt(x2+y2)) .

Nous devons déterminer la vitesse requise uniquement lorsque les mortiers se réveillent (Awake) ou lorsque nous changeons sa portée en mode Play. Par conséquent, nous allons le suivre à l'aide du champ et le calculer dans Awake et 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))); } 

Cependant, en raison des limites de la précision des calculs en virgule flottante, déterminer la cible très près de la plage maximale peut être erroné. Par conséquent, lors du calcul de la vitesse requise, nous ajoutons une petite quantité à la plage. De plus, le rayon du collisionneur de l'ennemi élargit essentiellement le rayon maximum de la portée de la tour. Nous l'avons fait égal à 0,125, mais avec une augmentation de l'échelle de l'ennemi, il peut doubler autant que possible, nous allons donc augmenter la portée réelle d'environ 0,25, par exemple de 0,25001.

  float x = targetingRange + 0.25001f; 

Ensuite, appliquez l'équation dérivée pour la vitesse d'un tir dans Launch .

  float s = launchSpeed; 


Appliquez la vitesse calculée à la plage de visée 3.5.

Tournage


En ayant le calcul correct de la trajectoire, vous pouvez vous débarrasser des objectifs de test relatifs. Vous devez maintenant passer le point de Launch à la cible.

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

De plus, les photos ne sont pas tirées dans chaque image. Nous devons suivre le processus du tir de la même manière que le processus de création d'ennemis et capturer une cible aléatoire lorsque le moment est venu pour le tir dans GameUpdate . Mais à ce stade, aucun objectif n'est disponible. Dans ce cas, nous continuons le processus de tir, mais sans autre accumulation. Pour éviter une boucle infinie, vous devez la rendre un peu inférieure à 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; } } } 

Nous ne suivons pas les cibles entre les tirs, mais nous devons faire pivoter correctement le mortier pendant les tirs. Vous pouvez utiliser la direction horizontale de la prise de vue pour faire pivoter le mortier horizontalement à l'aide de Quaternion.LookRotation . Nous avons également besoin de  tan theta appliquer l'angle de prise de vue pour la composante Y du vecteur de direction. Cela fonctionnera car la direction horizontale a une longueur de 1, c'est-à-dire  tan theta= sin theta .


Décomposition du vecteur de virage du look.

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

Pour toujours voir la trajectoire des plans, vous pouvez ajouter un paramètre à Debug.DrawLine qui permet de les dessiner pendant une longue période.

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


Viser.

Coquillages


Le sens du calcul des trajectoires est que nous savons désormais tirer des obus. Ensuite, nous devons les créer et les filmer.

Usine de guerre


Nous avons besoin d'une usine pour instancier des objets shell. En l'air, les obus existent seuls et ne dépendent plus des mortiers qui les ont tirés. Par conséquent, ils ne doivent pas être traités par la tour de mortier et l'usine de contenu de carreaux n'est pas non plus adaptée à cela.Créons créer pour tout ce qui est lié aux armes, une nouvelle usine et appelons ça l'usine de guerre. Créez d'abord un résumé WarEntityavec une propriété OriginFactoryet une méthode 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); } } 

Créez ensuite une entité spécifique Shellpour les coquilles.

 using UnityEngine; public class Shell : WarEntity { } 

Créez ensuite WarFactorycelui qui créera le projectile à l'aide de la propriété getter publique.

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

Créez un préfabriqué pour le projectile. J'ai utilisé un cube simple avec la même échelle de 0,25 et un matériau sombre, ainsi qu'un composant Shell. Créez ensuite l'équipement d'usine et affectez-lui le préfabriqué du projectile.


Usine de guerre.

Comportement du jeu


Pour déplacer les obus, ils doivent être mis à jour. Vous pouvez utiliser la même approche que celle utilisée Gamepour mettre à jour le statut des ennemis. En fait, nous pouvons même généraliser cette approche en créant un composant abstrait GameBehaviorqui étend MonoBehaviouret ajoute une méthode virtuelle GameUpdate.

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

Faites maintenant le refactoring EnemyCollection, en le transformant 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; } } } } 

Faisons-le WarEntitys'étendre GameBehavior, non MonoBehavior.

 public abstract class WarEntity : GameBehavior { … } 

Nous ferons de même pour Enemy, cette fois en remplaçant la méthode GameUpdate.

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

Désormais, Gameil devra suivre deux collections, l'une pour les ennemis, l'autre pour les non-ennemis. Les non-ennemis doivent être mis à jour après tout le reste.

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

La dernière étape de la mise en œuvre d'une mise à niveau du shell consiste à les ajouter à une collection de non-ennemis. Faisons-le avec une fonction Gamequi sera une façade statique pour une usine de guerre afin que les projectiles puissent être créés par un défi Game.SpawnShell(). Pour que cela fonctionne, vous Gamedevez avoir un lien vers War Factory et garder une trace de votre propre instance.

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


Jeu avec l'usine de guerre.

Une façade statique est-elle une bonne solution?
, , .

Nous tirons un obus


Après avoir créé une instance du projectile, il devrait voler le long de son chemin jusqu'à ce qu'il atteigne le but final. Pour ce faire, ajoutez à la Shellméthode Initializeet utilisez-la pour spécifier le point du tir, le point de la cible et la vitesse du tir.

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

Nous pouvons maintenant créer un shell MortarTower.Launchet l'envoyer sur la route.

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

Mouvement de projectile


Pour se Shelldéplacer, nous devons suivre la durée de son existence, c'est-à-dire le temps écoulé depuis la prise de vue. Ensuite, nous pouvons calculer sa position GameUpdate. Nous le faisons toujours par rapport à son point de tir, afin que le projectile suive parfaitement la trajectoire quel que soit le taux de rafraîchissement.

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


Bombardements.

Pour aligner les coquilles avec leurs trajectoires, nous devons les faire regarder le long du vecteur dérivé, qui est leur vitesse au moment correspondant.

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


Les obus tournent.

Nous nettoyons le jeu


Maintenant qu'il est clair que les obus volent exactement comme ils le devraient, vous pouvez supprimer les MortarTower.Launchtrajectoires de la visualisation.

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

De plus, nous devons nous assurer que les obus sont détruits après avoir atteint la cible. Puisque nous visons toujours le sol, cela peut être fait en vérifiant Shell.GameUpdatesi la position verticale est inférieure à zéro. Vous pouvez le faire immédiatement après les avoir calculés, avant de changer la position et de tourner le projectile.

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

Détonation


Nous tirons des obus car ils contiennent des explosifs. Lorsque le projectile atteint sa cible, il doit exploser et infliger des dégâts à tous les ennemis dans la zone de l'explosion. Le rayon de l'explosion et les dégâts infligés dépendent du type d'obus tirés par le mortier, nous allons donc leur ajouter MortarTowerdes options de configuration.

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


Rayon d'explosion et 1,5 dégâts de 15 obus.

Cette configuration n'est importante que pendant l'explosion, elle doit donc être ajoutée à Shellet sa méthode 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 ne doit transmettre des données au projectile qu'après sa création.

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

Pour tirer sur des ennemis à portée, le projectile doit capturer des cibles. Nous avons déjà du code pour cela, mais il est dans Tower. Puisqu'il est utile pour tout ce qui a besoin d'un objectif, copiez sa fonctionnalité dans TargetPointet rendez-la disponible statiquement. Ajoutez une méthode pour remplir le tampon, une propriété pour obtenir la quantité tamponnée et une méthode pour obtenir la cible tamponnée.

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

Maintenant, nous pouvons recevoir toutes les cibles à portée jusqu'à la taille maximale du tampon et infliger des dégâts lors de la détonation 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; } 


Détonation d'obus.

Vous pouvez également ajouter à une TargetPointpropriété statique pour obtenir une cible aléatoire à partir du tampon.

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

Cela nous permettra de simplifier Tower, car vous pouvez maintenant utiliser pour rechercher une cible aléatoire TargetPoint.

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

Les explosions


Tout fonctionne, mais cela ne semble toujours pas très crédible. Vous pouvez améliorer l'image en ajoutant une visualisation de l'explosion lors de la détonation de l'obus. Cela aura non seulement l'air plus intéressant, mais donnera également au joueur des commentaires utiles. Pour ce faire, nous allons créer un préfabriqué de l'explosion comme un faisceau laser. Seulement, ce sera une sphère plus transparente de couleur vive. Ajoutez un nouveau composant d'entité Explosionavec une durée personnalisée. Une demi-seconde suffira. Ajoutez-lui une méthode Initializequi définit la position et le rayon de l'explosion. Lors du réglage de l'échelle, vous devez doubler le rayon, car le rayon du maillage de la sphère est de 0,5. C'est également un bon endroit pour infliger des dégâts à tous les ennemis à portée, nous allons donc également ajouter un paramètre de dégâts. De plus, il a besoin d'une méthode GameUpdatepour vérifier si le temps presse.

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

Ajoutez une explosion à WarFactory.

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


Usine de guerre avec une explosion.

Ajoutez également à la Gameméthode de façade.

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

Maintenant, il Shellpeut générer et déclencher une explosion en atteignant la cible. L'explosion elle-même causera des dommages.

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


Explosions d'obus.

Des explosions plus douces


Les sphères immuables au lieu d'explosions ne sont pas très belles. Vous pouvez les améliorer en animant l'opacité et l'échelle. Vous pouvez utiliser une formule simple pour cela, mais utilisons des courbes d'animation plus faciles à configurer. Ajoutez pour ces Explosiondeux champs de configuration AnimationCurve. Nous utiliserons les courbes pour ajuster les valeurs sur la durée de vie de l'explosion, et le temps 1 indiquera la fin de l'explosion, quelle que soit sa vraie durée. Il en va de même pour l'échelle et le rayon de l'explosion. Cela simplifiera leur configuration.

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

L'opacité commencera et se terminera par zéro, graduellement ajustée à une valeur moyenne de 0,3. L'échelle commencera à 0,7, augmentera rapidement, puis approchera lentement de 1.


Courbes d'explosion.

Pour définir la couleur du matériau, nous utiliserons le bloc des propriétés du matériau. où noir est la variable d'opacité. L'échelle est désormais définie sur GameUpdate, mais nous devons effectuer le suivi à l'aide du champ de rayon. Dans Initialize, vous pouvez utiliser l'échelle de doubler. Les valeurs des courbes sont trouvées en les appelant Evaluateavec un argument, calculé comme la durée de vie actuelle de l'explosion, divisé par la durée de l'explosion.

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


Explosions animées.

Coquilles de traçage


Comme les coquilles sont petites et ont une vitesse assez élevée, elles peuvent être difficiles à remarquer. Et si vous regardez la capture d'écran d'une seule image, les trajectoires sont complètement incompréhensibles. Vous pouvez les rendre plus évidents en ajoutant un effet de trace à vos coquilles. Pour les obus conventionnels, ce n'est pas très réaliste, mais on peut dire que ce sont des traceurs. Ces munitions sont spécialement conçues pour qu'elles laissent une marque lumineuse, rendant leurs trajectoires visibles.

Il existe différentes façons de créer des traces, mais vous en utiliserez une très simple. Nous refaisons les explosions pour Shellcréer une petite explosion dans chaque image. Ces explosions ne causeront aucun dommage, donc capturer des cibles sera un gaspillage de ressources. Ajouter àExplosionprise en charge de cette utilisation en faisant le dommage être fait si elle est supérieure à zéro, puis rendre le paramètre de dommage Initializefacultatif.

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

Nous allons créer une explosion à la fin Shell.GameUpdateavec un petit rayon, par exemple 0,1, pour les transformer en coques de traçage. Il convient de noter qu'avec cette approche, des explosions seront créées image par image, c'est-à-dire qu'elles dépendent de la fréquence d'images, mais pour un effet aussi simple, cela est admissible.

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

image

Traceurs de projectiles. Article PDF du

référentiel de didacticiels

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


All Articles