Création de Tower Defense dans Unity: Enemies

[ Première partie: tuiles et trouver le chemin ]

  • Placement des points de création ennemis.
  • L'apparition des ennemis et leur mouvement à travers le champ.
  • Créer un mouvement fluide à vitesse constante.
  • Modifiez la taille, la vitesse et l'emplacement des ennemis.

Ceci est la deuxième partie d'un tutoriel sur un jeu de tower defense simple. Il examine le processus de création d'ennemis et leur mouvement vers le point final le plus proche.

Ce didacticiel est réalisé dans Unity 2018.3.0f2.


Ennemis sur le chemin du point final.

Points de création (apparition) ennemis


Avant de commencer à créer des ennemis, nous devons décider où les placer sur le terrain. Pour ce faire, nous allons créer des points d'apparition.

Contenu des tuiles


Un point d'apparition est un autre type de contenu de tuile, alors ajoutez-y une entrée dans GameTileContentType .

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

Et puis créez un préfabriqué pour le visualiser. Un doublon du préfabriqué du point de départ nous convient tout à fait, il suffit de changer son type de contenu et de lui donner un autre matériau. Je l'ai fait orange.


Configuration du point d'apparition.

Ajoutez la prise en charge des points d'apparition à la fabrique de contenu et donnez-lui un lien vers le préfabriqué.

  [SerializeField] GameTileContent spawnPointPrefab = default; … public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); case GameTileContentType.Wall: return Get(wallPrefab); case GameTileContentType.SpawnPoint: return Get(spawnPointPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; } 


Usine avec prise en charge des points d'apparition.

Activer ou désactiver les points d'apparition


La méthode pour changer l'état du point d' GameBoard , comme les autres méthodes de commutation, nous ajouterons au GameBoard . Mais les points d'apparition n'affectent pas la recherche du chemin, donc après le changement, nous n'avons pas besoin de chercher de nouveaux chemins.

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

Le jeu n'a de sens que si nous avons des ennemis et qu'ils ont besoin de points d'apparition. Par conséquent, le champ de jeu doit contenir au moins un point d'apparition. Nous aurons également besoin d'accéder aux points d'apparition à l'avenir, lorsque nous ajouterons des ennemis, alors utilisons la liste pour suivre toutes les tuiles avec ces points. Nous mettrons à jour la liste lors du changement d'état du point d'apparition et empêcherons la suppression du dernier point d'apparition.

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

La méthode Initialize doit maintenant définir le point d'apparition pour créer l'état correct initial du champ. Disons simplement inclure la première tuile, qui est dans le coin inférieur gauche.

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

Nous allons faire le toucher alternatif maintenant changer l'état des points d'apparition, mais lorsque vous maintenez la touche Maj gauche (la frappe est vérifiée par la méthode Input.GetKey ), l'état du point final changera

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


Champ avec des points d'apparition.

Accédez aux points d'apparition


Le terrain s'occupe de toutes ses tuiles, mais les ennemis ne sont pas de sa responsabilité. Nous allons permettre d'accéder à ses points d' GetSpawnPoint via la méthode GetSpawnPoint commune avec un paramètre d'index.

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

Pour savoir quels indices sont corrects, des informations sont nécessaires sur le nombre de points d'apparition, nous allons donc le rendre général en utilisant la propriété getter générale.

  public int SpawnPointCount => spawnPoints.Count; 

Apparition de l'ennemi


L'apparition d'un ennemi est quelque peu similaire à la création du contenu d'une tuile. Nous créons une instance préfabriquée via l'usine, que nous plaçons ensuite sur le terrain.

Usines


Nous allons créer une usine pour les ennemis qui mettra tout ce qu'elle crée sur sa propre scène. Cette fonctionnalité est commune à l'usine que nous avons déjà, alors mettons le code pour elle dans la classe de base commune GameObjectFactory . Nous CreateGameObjectInstance besoin que d'une CreateGameObjectInstance méthode CreateGameObjectInstance avec un paramètre préfabriqué commun, qui crée et renvoie une instance, et gère également la scène entière. Nous rendons la méthode protected , c'est-à-dire qu'elle ne sera disponible que pour la classe et tous les types qui en hériteront. C'est tout ce que fait la classe; elle n'est pas destinée à être utilisée comme une usine entièrement fonctionnelle. Par conséquent, nous le marquons comme abstract , ce qui ne nous permettra pas de créer des instances de ses objets.

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

Modifiez le GameTileContentFactory afin qu'il GameTileContentFactory ce type de fabrique et utilise CreateGameObjectInstance dans sa méthode Get , puis supprimez-en le code de contrôle de scène.

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

Après cela, créez un nouveau type EnemyFactory qui crée une instance d'un préfabriqué Enemy à l'aide de la méthode Get avec la méthode Reclaim .

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

Au départ, le nouveau type Enemy n'avait qu'à garder une trace de son usine d'origine.

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

Préfabriqué


Les ennemis ont besoin de visualisation, ce qui peut être n'importe quoi - un robot, une araignée, un fantôme, quelque chose de plus simple, par exemple, un cube, que nous utilisons. Mais en général, l'ennemi a un modèle 3D de toute complexité. Pour assurer son support pratique, nous utiliserons l'objet racine pour la hiérarchie préfabriquée ennemie, à laquelle seul le composant Enemy est attaché.


Racine préfabriquée

Créons cet objet le seul élément enfant, qui sera la racine du modèle. Il doit avoir des valeurs unitaires de transformation.


La racine du modèle.

La tâche de cette racine de modèle est de positionner le modèle 3D par rapport au point d'origine local des coordonnées de l'ennemi, afin qu'il le considère comme un point de référence sur lequel l'ennemi se tient ou se bloque. Dans notre cas, le modèle sera un cube demi-taille standard, auquel je donnerai une couleur bleu foncé. Nous en faisons un enfant de la racine du modèle et définissons la position Y à 0,25 pour qu'il se tienne au sol.


Modèle de cube

Ainsi, le préfabriqué ennemi se compose de trois objets imbriqués: la racine préfabriquée, la racine du modèle et le cube. Cela peut sembler être un buste pour un simple cube, mais un tel système vous permet de déplacer et d'animer n'importe quel ennemi sans vous soucier de ses fonctionnalités.


La hiérarchie préfabriquée de l'ennemi.

Créons une usine ennemie et affectons-lui un préfabriqué.


Usine d'actifs.

Placer des ennemis sur le terrain


Pour mettre des ennemis sur le terrain, le Game doit recevoir un lien vers l'usine des ennemis. Étant donné que nous avons besoin de beaucoup d'ennemis, nous ajouterons une option de configuration pour ajuster la vitesse d'apparition, exprimée en nombre d'ennemis par seconde. Une plage acceptable est de 0,1 à 10 avec une valeur par défaut de 1.

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


Jeu avec une usine ennemie et une vitesse de reproduction 4.

Nous suivrons la progression du frai dans Update , en l'augmentant de la vitesse multipliée par le temps delta. Si la valeur prggress dépasse 1, alors nous la décrémentons et engendrons l'ennemi en utilisant la nouvelle méthode SpawnEnemy . Nous continuons à le faire jusqu'à ce que la progression dépasse 1 au cas où la vitesse est trop élevée et le temps de trame est très long afin que plusieurs ennemis ne soient pas créés en même temps.

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

N'est-il pas nécessaire de mettre à jour la progression dans FixedUpdate?
Oui, c'est possible, mais ces horaires précis ne sont pas nécessaires pour le jeu de tower defense. Nous allons simplement mettre à jour l'état du jeu à chaque image et le faire fonctionner suffisamment bien pour tout delta de temps.

Laissez SpawnEnemy obtenir un point d' SpawnEnemy aléatoire sur le terrain et créez un ennemi sur cette tuile. Nous donnerons à l' Enemy la méthode SpawnOn pour se SpawnOn correctement.

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

Pour l'instant, tout ce que SpawnOn a à faire est de définir sa propre position égale au centre de la tuile. Puisque le modèle préfabriqué est correctement positionné, le cube ennemi sera au-dessus de cette tuile.

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


Les ennemis apparaissent aux points d'apparition.

Déplacer les ennemis


Après que l'ennemi soit apparu, il doit commencer à se déplacer le long du chemin vers le point d'arrivée le plus proche. Pour ce faire, vous devez animer les ennemis. Nous commençons par une simple glisse lisse d'une tuile à l'autre, puis rendons leur mouvement plus difficile.

Collection d'ennemis


Pour mettre à jour le statut des ennemis, nous utiliserons la même approche que celle utilisée dans la série de didacticiels sur la gestion d'objets . Nous ajoutons à Enemy méthode générale de GameUpdate , qui retourne des informations sur s'il est vivant, ce qui à ce stade sera toujours vrai. Pour l'instant, faites-le simplement avancer en fonction du delta du temps.

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

De plus, nous devons maintenir une liste des ennemis vivants et les mettre à jour tous, en les supprimant de la liste des ennemis morts. Nous pouvons mettre tout ce code dans un Game , mais au lieu de cela, isolez-le et créez un type EnemyCollection . Il s'agit d'une classe sérialisable qui n'hérite de rien. Nous lui donnons une méthode générale pour ajouter un ennemi et une autre méthode pour mettre à jour la collection entière.

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

Maintenant, le Game sera suffisant pour créer une seule collection de ce type, dans chaque image, mettez-la à jour et ajoutez-y des ennemis créés. Nous mettrons à jour les ennemis immédiatement après l'apparition possible d'un nouvel ennemi afin que la mise à jour ait lieu instantanément.

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


Les ennemis avancent.

Mouvement le long du chemin


Les ennemis se déplacent déjà, mais jusqu'à présent ne suivent pas le chemin. Pour ce faire, ils doivent savoir où aller ensuite. Par conséquent, donnons à GameTile propriété getter commune pour obtenir la prochaine tuile sur le chemin.

  public GameTile NextTileOnPath => nextOnPath; 

Connaissant la tuile dont vous voulez sortir et la tuile dans laquelle vous devez entrer, les ennemis peuvent déterminer les points de départ et d'arrivée pour déplacer une tuile. L'ennemi peut interpoler la position entre ces deux points, en suivant leur mouvement. Une fois le déplacement terminé, ce processus est répété pour la tuile suivante. Mais les chemins peuvent changer à tout moment. Au lieu de déterminer où aller plus loin dans le processus de mouvement, nous continuons simplement à nous déplacer le long de l'itinéraire prévu et à le vérifier, en atteignant la tuile suivante.

Laissez l' Enemy suivre les deux tuiles afin qu'elles ne soient pas affectées par un changement de chemin. Il suivra également les positions afin que nous n'ayons pas à les recevoir dans chaque image et suivra le processus de déplacement.

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

Initialisez ces champs dans SpawnOn . Le premier point est la tuile à partir de laquelle l'ennemi se déplace, et le point final est la prochaine tuile sur le chemin. Cela suppose que la tuile suivante existe, sauf si l'ennemi a été créé au point final, ce qui devrait être impossible. Ensuite, nous mettons en cache les positions des tuiles et réinitialisons la progression. Nous n'avons pas besoin de définir la position de l'ennemi ici, car sa méthode GameUpdate appelée dans le même cadre.

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

L'incrément de progression sera effectué dans GameUpdate . Ajoutons un delta de temps constant pour que les ennemis se déplacent à une vitesse d'une tuile par seconde. Une fois la progression terminée, nous déplaçons les données afin que To devienne la valeur From et que le nouveau To soit la tuile suivante sur le chemin. Ensuite, nous décrémentons la progression. Lorsque les données deviennent pertinentes, nous interpolons la position de l'ennemi entre From et To . Puisque l'interpolateur est une progression, sa valeur est nécessairement comprise entre 0 et 1, nous pouvons donc utiliser s Vector3.LerpUnclamped .

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

Cela oblige les ennemis à suivre le chemin, mais n'agit pas lorsqu'ils atteignent le point final. Par conséquent, avant de modifier les positions From et To , vous devez comparer la tuile suivante du chemin avec null . Si oui, alors nous avons atteint le point final et l'ennemi a terminé le mouvement. Nous exécutons Reclaim pour cela et retournons false .

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



Les ennemis suivent le chemin le plus court.

Les ennemis se déplacent désormais du centre d'une tuile à une autre. Il vaut la peine de considérer qu'ils ne changent leur état de mouvement qu'au centre des tuiles, ils ne peuvent donc pas répondre immédiatement aux changements sur le terrain. Cela signifie que parfois les ennemis se déplacent à travers les murs qui viennent d'être définis. Une fois qu'ils ont commencé à se déplacer vers la cellule, rien ne les arrêtera. C'est pourquoi les murs ont également besoin de vrais chemins.


Les ennemis réagissent aux changements de trajectoire.

Mouvement bord à bord


Le mouvement entre les centres des tuiles et un changement brusque de direction semble normal pour un jeu abstrait dans lequel les ennemis déplacent des cubes, mais généralement le mouvement fluide est plus beau. La première étape de sa mise en œuvre n'est pas de se déplacer le long des centres, mais le long des bords des carreaux.

Le point de bord entre les tuiles adjacentes peut être trouvé en faisant la moyenne de leurs positions. Au lieu de le calculer à chaque étape pour chaque ennemi, nous le calculerons uniquement lors du changement de chemin dans GameTile.GrowPathTo . Rendez-le disponible à l'aide de la propriété ExitPoint .

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

Le seul cas particulier est la cellule finale, dont le point de sortie sera son centre.

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

Changez l' Enemy pour qu'il utilise des points de sortie, pas des centres de tuiles.

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


Les ennemis se déplacent entre les bords.

Un effet secondaire de ce changement est que lorsque les ennemis se retournent en raison d'un changement de trajectoire, ils restent immobiles pendant une seconde.


En tournant, les ennemis s'arrêtent.

Orientation


Bien que les ennemis se déplacent le long des chemins jusqu'à ce qu'ils changent d'orientation. Pour pouvoir regarder dans la direction du mouvement, ils doivent connaître la direction du chemin qu'ils suivent. Nous le déterminerons également lors de la recherche de voies, afin que cela ne soit pas fait par les ennemis.

Nous avons quatre directions: nord, est, sud et ouest. Énumérons-les.

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

Ensuite, nous donnons la propriété GameTile pour stocker la direction de son chemin.

  public Direction PathDirection { get; private set; } 

Ajoutez un paramètre de direction à GrowTo , qui définit la propriété. Puisque nous développons un chemin de la fin au début, la direction sera opposée à celle à partir de laquelle nous développons le chemin.

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

Nous devons convertir les directions en virages exprimés en quaternions. Ce serait pratique si nous pouvions simplement appeler GetRotation pour la direction, alors faisons-le en créant une méthode d'extension. Ajoutez la méthode statique générale DirectionExtensions , donnez-lui un tableau pour mettre en cache les quaternions nécessaires, ainsi que la méthode GetRotation pour renvoyer la valeur de direction correspondante. Dans ce cas, il est judicieux de placer la classe d'extension dans le même fichier que le type d'énumération.

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

Qu'est-ce qu'une méthode d'extension?
Une méthode d'extension est une méthode statique à l'intérieur d'une classe statique qui se comporte comme une méthode d'instance d'un certain type. Ce type peut être une classe, une interface, une structure, une valeur primitive ou une énumération. Le premier argument de la méthode d'extension doit avoir le this . Il définit la valeur du type et de l'instance avec lesquels la méthode fonctionnera. Cette approche signifie que l'expansion des propriétés n'est pas possible.

Cela vous permet-il d'ajouter des méthodes à quelque chose? Oui, tout comme vous pouvez écrire n'importe quelle méthode statique dont le paramètre est de n'importe quel type.

Maintenant, nous pouvons faire tourner l' Enemy lors de la ponte et chaque fois que nous entrons dans une nouvelle tuile. Après la mise à jour des données, la vignette From nous donne une direction.

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

Changement de direction


Au lieu de changer instantanément de direction, il est préférable d'interpoler les valeurs entre les virages, de la même manière que nous avons interpolé entre les positions. Pour passer d'une orientation à une autre, il faut connaître le changement de direction qu'il faut faire: sans tourner, tourner à droite, tourner à gauche ou revenir en arrière. Nous ajoutons pour cela une énumération, qui à nouveau peut être placée dans le même fichier que la Direction , car elles sont petites et étroitement liées.

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

Ajoutez une autre méthode d'extension, cette fois GetDirectionChangeTo , qui renvoie un changement de direction de la direction actuelle à la suivante. Si les directions coïncident, il n'y a pas de décalage. Si le suivant est plus que le courant, alors c'est un virage à droite. Mais puisque les directions se répètent, la même situation se présentera lorsque la suivante sera de trois fois inférieure à l'actuelle. Avec un virage à gauche, ce sera la même chose, seules l'addition et la soustraction changeront de place. Le seul cas restant est un retour en arrière.

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

Nous faisons une rotation dans une seule dimension, donc une interpolation linéaire des angles nous suffira. Ajoutez une autre méthode d'expansion qui obtient l'angle de direction en degrés.

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

Vous devez maintenant Enemysuivre la direction, le changement de direction et les angles entre lesquels vous devez effectuer l'interpolation.

  Direction direction; DirectionChange directionChange; float directionAngleFrom, directionAngleTo; 

SpawnOndevient plus difficile, nous allons donc déplacer le code de préparation de l'état vers une autre méthode. Nous désignerons l'état initial de l'ennemi comme un état d'introduction, nous l'appellerons donc PrepareIntro. Dans cet état, l'ennemi se déplace du centre vers le bord de sa tuile initiale, il n'y a donc pas de changement de direction. Les angles Fromet Toles mêmes.

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

À ce stade, nous créons quelque chose comme une petite machine d'état. Pour simplifier les choses GameUpdate, déplacez le code d'état vers une nouvelle méthode PrepareNextState. Nous ne laisserons que les changements de tuiles Fromet To, parce que nous les utilisons ici pour vérifier si l'ennemi a terminé le chemin.

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

Lors de la transition vers un nouvel état, vous devez toujours changer de position, trouver un changement de direction, mettre à jour la direction actuelle et déplacer l'angle Tosur From. Nous ne définissons plus de virage.

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

D'autres actions dépendent d'un changement de direction. Ajoutons une méthode pour chaque option. Dans le cas où nous avançons, l'angle Tocoïncide avec la direction du chemin de la cellule actuelle. De plus, nous devons régler la rotation de sorte que l'ennemi regarde droit devant.

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

Dans le cas d'un virage, nous ne tournons pas instantanément. Nous devons interpoler sous un angle différent: 90 ° de plus pour tourner à droite, 90 ° de moins pour tourner à gauche et 180 ° de plus pour revenir en arrière. Pour éviter de tourner dans la mauvaise direction en raison d'un changement des valeurs d'angle de 359 ° à 0 °, l'angle Todoit être indiqué par rapport à la direction actuelle. Nous n'avons pas à nous inquiéter que l'angle devienne inférieur à 0 ° ou supérieur à 360 °, car nous Quaternion.Eulerpouvons le gérer.

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

En fin de compte, PrepareNextStatenous pouvons utiliser switchpour changer de direction afin de décider laquelle des quatre méthodes appeler.

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

Maintenant, à la fin, GameUpdatenous devons vérifier si la direction a changé. Si tel est le cas, interpolez entre les deux coins et définissez la rotation.

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


Les ennemis tournent.

Mouvement de courbe


Nous pouvons améliorer le mouvement en faisant bouger les ennemis le long d'une courbe en tournant. Au lieu de marcher d'un bord à l'autre des tuiles, laissez-les marcher d'un quart de cercle. Le centre de ce cercle se trouve dans un coin commun aux tuiles Fromet Tosur le même bord le long duquel l'ennemi est entré dans la tuile From.


Une rotation d'un quart de cercle pour tourner à droite.

Nous pouvons le réaliser en déplaçant l'ennemi dans un arc en utilisant la trigonométrie, tout en le tournant. Mais cela peut être simplifié en utilisant uniquement la rotation, en déplaçant temporairement l'origine locale des coordonnées de l'ennemi au centre du cercle. Pour ce faire, nous devons changer la position du modèle ennemi, nous allons donc donner un Enemylien vers ce modèle, accessible via le champ de configuration.

  [SerializeField] Transform model = default; 


Ennemi en référence au modèle.

Pour se préparer à avancer ou à reculer, le modèle doit se déplacer vers la position standard, à l'origine locale des coordonnées de l'ennemi. Sinon, le modèle doit être décalé de la moitié de l'unité de mesure - le rayon du cercle de rotation, loin du point de retournement.

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

Maintenant, l'ennemi lui-même doit être déplacé au tournant. Pour ce faire, il doit également être déplacé de la moitié de l'unité de mesure, mais le décalage exact dépend de la direction. Ajoutons à cela Directionune méthode d'extension auxiliaire GetHalfVector.

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

Ajoutez le vecteur correspondant en tournant à droite ou à gauche.

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

Et lorsque vous reculez, la position doit être le point de départ habituel.

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

De plus, lors du calcul du point de sortie, nous pouvons utiliser la GameTile.GrowPathTomoitié du vecteur afin de ne pas avoir besoin d'accéder aux deux positions des tuiles.

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

Maintenant, lors du changement de direction, nous n'avons pas à interpoler la position Enemy.GameUpdate, car la rotation est engagée dans le mouvement.

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


Les ennemis se plient en douceur autour des coins.

Vitesse constante


Jusqu'à ce point, la vitesse des ennemis a toujours été égale à une tuile par seconde, quelle que soit la façon dont ils se déplacent à l'intérieur de la tuile. Mais la distance qu'ils parcourent dépend de leur condition, donc leur vitesse, exprimée en unités par seconde, varie. Pour que cette vitesse soit constante, nous devons changer la vitesse de progression en fonction de l'état. Par conséquent, ajoutez le champ du multiplicateur de progression et utilisez-le pour mettre le delta à l'échelle GameUpdate.

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

Mais si la progression change en fonction de l'état, la valeur de progression restante ne peut pas être utilisée directement pour l'état suivant. Par conséquent, avant de préparer un nouvel état, nous devons normaliser les progrès et appliquer le nouveau multiplicateur déjà dans un nouvel état.

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

Avancer ne nécessite pas de changements, par conséquent, il utilise un facteur de 1. En tournant à droite ou à gauche, l'ennemi passe un quart de cercle avec un rayon de ½, donc la distance parcourue est de ¼π. progrességal à un divisé par cette valeur. Le retour en arrière ne devrait pas prendre trop de temps, alors doublez la progression pour que cela prenne une demi-seconde. Enfin, le mouvement d'introduction ne couvre que la moitié de la tuile, par conséquent, pour maintenir une vitesse constante, sa progression doit également être doublée.

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

Pourquoi la distance est-elle égale à 1/4 * pi?
La circonférence est 2π fois le rayon. Tourner vers la droite ou la gauche ne couvre qu'un quart de cette longueur, et le rayon est ½, donc la distance est ½π × ½.

État final


Puisque nous avons un état d'introduction, ajoutons un dernier. Les ennemis disparaissent actuellement immédiatement après avoir atteint le point d'extrémité, mais reportons leur disparition jusqu'à ce qu'ils atteignent le centre de la tuile d'extrémité. Créons une méthode pour cela PrepareOutro, définissons le mouvement vers l'avant, mais uniquement au centre de la tuile avec une progression doublée pour maintenir une vitesse constante.

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

Afin de GameUpdatene pas détruire l'ennemi trop tôt, nous allons lui retirer le décalage de tuile. Il va le faire maintenant PrepareNextState. Ainsi, vérifier les nullretours trueuniquement après la fin de l'état final.

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

Dans PrepareNextStatenous commencerons par le déplacement des tuiles. Ensuite, après avoir défini la position From, mais avant de définir la position, Tonous vérifierons si la tuile est égale à la Tovaleur null. Si c'est le cas, préparez l'état final et ignorez le reste de la méthode.

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


Ennemis avec une vitesse constante et un état final.

Variabilité ennemie


Nous avons un flux d'ennemis, et ils sont tous le même cube, se déplaçant à la même vitesse. Le résultat ressemble plus à un long serpent qu'à des ennemis individuels. Rendons-les plus différents en randomisant leur taille, leur déplacement et leur vitesse.

Plage de valeurs flottantes


Nous allons changer les paramètres des ennemis, en choisissant aléatoirement leurs caractéristiques dans la plage de valeurs. La structure FloatRangeque nous avons créée dans l'article Gestion des objets, Configuration des formes sera utile ici , alors copions-la. Les seuls changements consistaient à ajouter un constructeur avec un paramètre et à ouvrir l'accès au minimum et au maximum à l'aide des propriétés en lecture seule, de sorte que l'intervalle était immuable.

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

Nous copions également l'attribut qui lui est attribué afin de limiter son intervalle.

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

Nous n'avons besoin que de la visualisation du curseur, alors copiez-le FloatRangeSliderDrawerdans le dossier Editor .

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

Échelle du modèle


Nous commencerons par changer l'échelle de l'ennemi. Ajoutez EnemyFactoryles paramètres d'échelle à l' option. L'intervalle d'échelle ne doit pas être trop grand, mais suffisant pour créer des variétés miniatures et gigantesques d'ennemis. Tout ce qui se situe entre 0,5 et 2 avec une valeur standard de 1. Nous choisirons une échelle aléatoire dans cet intervalle Getet la passerons à l'ennemi par une nouvelle méthode Initialize.

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

La méthode Enemy.Initializedéfinit simplement l'échelle de son modèle qui est la même dans toutes les dimensions.

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

inspecteur

scène

La gamme d'échelles est de 0,5 à 1,5.

Décalage du chemin


Pour détruire davantage l'uniformité du flux d'ennemis, nous pouvons changer leur position relative à l'intérieur des tuiles. Ils avancent, donc le changement dans cette direction ne fait que modifier le moment de leur mouvement, ce qui n'est pas très visible. Par conséquent, nous allons les déplacer sur le côté, loin du chemin idéal passant par les centres des carreaux. Ajoutez un EnemyFactorydécalage de chemin à l' intervalle et passez le décalage aléatoire à la méthode Initialize. Le décalage peut être négatif ou positif, mais jamais supérieur à ½, car cela déplacerait l'ennemi vers une tuile voisine. De plus, nous ne voulons pas que les ennemis dépassent les tuiles qu'ils suivent, donc en fait l'intervalle sera moindre, par exemple 0,4, mais les vraies limites dépendent de la taille de l'ennemi.

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

Puisque le déplacement du chemin affecte le chemin parcouru, Enemyil est nécessaire de le suivre.

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

Lorsque vous vous déplacez exactement droit (pendant le mouvement d'introduction, final ou normal vers l'avant), nous appliquons simplement le décalage directement au modèle. La même chose se produit lorsque vous revenez en arrière. Avec un virage à droite ou à gauche, on déplace déjà le modèle, qui devient relatif au déplacement du chemin.

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

Puisque le déplacement du chemin pendant la rotation change le rayon, nous devons changer le processus de calcul du multiplicateur de progression. Le décalage de trajectoire doit être soustrait de ½ pour obtenir le rayon du virage à droite, et ajouté dans le cas d'un virage à gauche.

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

Nous obtenons également le rayon de braquage en tournant à 180 °. Dans ce cas, nous couvrons la moitié du cercle avec un rayon égal au décalage du chemin, donc la distance est π fois le décalage. Cependant, cela ne fonctionne pas lorsque le déplacement est nul, et pour les petits déplacements, les virages sont trop rapides. Pour éviter les virages instantanés, nous pouvons forcer le rayon minimum à calculer la vitesse, disons 0,2.

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

inspecteur


Le décalage de chemin est compris entre −0,25 et 0,25.

Notez que désormais, les ennemis ne modifient jamais leur déplacement relatif, même en tournant. Par conséquent, la longueur totale du chemin pour chaque ennemi a la sienne.

Pour empêcher les ennemis d'atteindre les tuiles voisines, il faut également tenir compte de leur échelle maximale possible. Je viens de limiter la taille à une valeur maximale de 1, donc le décalage maximal autorisé pour le cube est de 0,25. Si la taille maximale était de 1,5, le déplacement maximal devrait être réduit à 0,125.

La vitesse


La dernière chose que nous randomisons est la vitesse des ennemis. Nous ajoutons un intervalle de plus pour cela EnemyFactoryet nous transférerons de la valeur à la copie créée de l'ennemi. Faisons-en le deuxième argument de la méthode Initialize. Les ennemis ne doivent pas être trop lents ou rapides pour que le jeu ne devienne pas trivialement simple ou impossible difficile. Limitons l'intervalle à 0,2–5. La vitesse est exprimée en unités par seconde, ce qui correspond aux tuiles par seconde uniquement lorsque vous avancez.

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

Maintenant, je Enemydois suivre et accélérer.

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

Lorsque nous n'avons pas défini la vitesse de manière explicite, nous avons simplement toujours utilisé la valeur 1. Il suffit maintenant de créer la dépendance du multiplicateur de progression sur la vitesse.

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



Vitesse comprise entre 0,75 et 1,25.

Donc, nous avons eu un beau flux d'ennemis se déplaçant vers le point final. Dans le prochain tutoriel, nous apprendrons comment les gérer. Vous voulez savoir quand il sortira? Suivez ma page sur Patreon !

référentiel

article PDF

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


All Articles