[Translation] Creating a Tower Defense Game in Unity: Enemies

[Translation] Creating a Tower Defense Game in Unity: Enemies


[ The first part: tiles and finding the path ]

  • Placing enemy creation points.
  • The appearance of enemies and their movement across the field.
  • Create smooth motion at a constant speed.
  • Change the size, speed and placement of enemies.

This is the second part of the tower defense simple game in the genre. It discusses the process of creating enemies and their movement to the nearest end point.

This tutorial is made in Unity 2018.3.0f2.


Enemies on the way to the end point.

The points of creation (spawn) of enemies


Before we start creating enemies, we need to decide where to place them on the field. For this we will create spawn points.

Tile contents


Spawning point is another type of tile content, so add an entry for it in GameTileContentType .

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

And then create a prefab to visualize it. A duplicate of the prefab of the starting point is quite suitable for us, just change its content type and give it another material. I made it orange.


Configuration of spawn points.

Add support for spawn points to the content factory and give it a link to the prefab.

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


Factory with support for spawn points.

Enable and disable spawn points


The method for switching the state of the spawn point, as well as other switching methods, we will add to the GameBoard . But spawn points do not affect the search for a path, so after the change we don’t need to look for new paths.

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

The game makes sense only if we have enemies, and spawn points are necessary for them. Therefore, the game field must contain at least one spawn point. We will also need access to the spawn points later when we add enemies, so let's use the list to keep track of all the tiles with these points. We will update the list when switching the spawn point state and prevent deleting the last spawn point.

  List & lt; GameTile & gt;  spawnPoints = new List & lt; GameTile & gt; ();

 ...

 public void ToggleSpawnPoint (GameTile tile) {
  if (tile.Content.Type == GameTileContentType.SpawnPoint) {
  if (spawnPoints.Count & gt; 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);
  }
 }  

The Initialize method now needs to set a spawn point to create the initial correct field state. Let's just turn on the first tile, which is in the lower left corner.

  public void Initialize (
  Vector2Int size, GameTileContentFactory contentFactory
 ) {
  ...

  ToggleDestination (tiles [tiles.Length/2]);
  ToggleSpawnPoint (tiles [0]);
 }  

We will now make the alternative touch switch the state of the spawn points, but when the left Shift is held down (the keystroke is checked using the Input.GetKey method) the state of the end point will be switched

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


A field with spawn points.

Accessing spawn points


Paul is engaged in all his tiles, but enemies are not his responsibility. We will make it possible to access its spawn points through the common GetSpawnPoint method with an index parameter.

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

To know which indices are correct, we need information on the number of spawn points, so we will make it common using the common getter property.

  public int SpawnPointCount = & gt;  spawnPoints.Count;  

Spawn enemies


Spawning an enemy is somewhat similar to creating a content tile. We create through the factory a copy of the prefab, which we then place on the field.

Factories


We will create a factory for our enemies, which will put everything that it creates on its own stage. This functionality is common with the factory that we already have, so let's put the code for it in the common GameObjectFactory common base class. A single CreateGameObjectInstance method with a common prefab parameter that creates and returns an instance, as well as managing the entire scene, will be sufficient for us. Let's make the method protected , that is, it will be available only to the class and all types that inherit from it. This is all that the class does; it is not intended to be used as a fully functional factory. Therefore, we mark it as abstract , which will not allow creating instances of its objects.

  using UnityEngine;
 using UnityEngine.SceneManagement;

 public abstract class GameObjectFactory: ScriptableObject {

 Scene scene;

 protected T CreateGameObjectInstance & lt; T & gt;  (T prefab) where T: MonoBehavior {
  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;
 }
 }  

Modify GameTileContentFactory so that it inherits this type of factory and uses CreateGameObjectInstance in its Get method, and then remove the scene control code from it.

  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) {//...
//}
 }  

After that, create a new EnemyFactory type that creates an instance of one Enemy prefab using the Get method along with the accompanying Reclaim method .

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

A new type of Enemy initially only needs to track its original factory.

  using UnityEngine;

 public class Enemy: MonoBehaviour {

 EnemyFactory originFactory;

 public EnemyFactory of OriginFactory {
  get = & gt;  originFactory;
  set {
  Debug.Assert (originFactory == null, "Redefined origin factory!");
  originFactory = value;
  }
 }
 }  

Prefab


Enemies need a visualization that can be any - a robot, a spider, a ghost, something more simple, for example, a cube, which we use. But in general, the enemy has a 3D model of any complexity. To ensure its convenient support, we will use the root object for the hierarchy of the enemy’s prefab, with only the Enemy component attached.


Prefab Root.

Create this object the only child element that will be the root of the model. It must have a single Transform value.


The root of the model.

The task of this model root is to position the 3D model relative to the local point of origin of the enemy, so that he considers it as the reference point over which the enemy is standing or hanging. In our case, the model will be a standard half-size cube, to which I will give a dark blue color. Let's make it a child of the model root and assign the Y position a value of 0.25 so that it stands on the ground.


Cube Model.

Thus, the enemy prefab consists of three nested objects: the prefab root, the model root and the cube. It may seem like a bust for a simple cube, but such a system allows you to move and animate any enemy without worrying about its features.


The enemy's prefab hierarchy.

Create a factory of enemies and assign it a prefab.


Asset Factory.

Place enemies on the field


To put enemies on the field, Game must receive a link to the enemy factory. Since we need a lot of enemies, we add a configuration option to adjust the speed of spacing, expressed in the number of enemies per second. A 0.1–10 interval with a default value of 1 seems acceptable.

  [SerializeField]
 EnemyFactory enemyFactory = default;

 [SerializeField, Range (0.1f, 10f)]
 float spawnSpeed ​​= 1f;  


Game with a factory of enemies and speed of spawning 4.

Progress spacing will be monitored in Update , increasing it by the speed multiplied by the time delta. If the prggress value exceeds 1, then decrement it and spawn the enemy using the new SpawnEnemy method. We continue to do this until progress exceeds 1 in case the speed is too high and the frame time is very long so that several enemies are not created at the same time.

  float spawnProgress;

 ...

 void Update () {
  ...

  spawnProgress + = spawnSpeed ​​* Time.deltaTime;
  while (spawnProgress & gt; = 1f) {
  spawnProgress - = 1f;
  SpawnEnemy ();
  }
 }  

Shouldn't you update the progress in FixedUpdate?
Yes, it is possible, but for a tower defense game such exact timings Not needed. We will simply update the state of the game every frame and make it work well enough for any time delta.

Let SpawnEnemy get a random spawn point from the field and create an enemy in this tile. We will give Enemy the SpawnOn method to correctly position itself.

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

For now, all that SpawnOn has to do is set its own position equal to the center of the tile. Since the prefab model is located correctly, the cube-enemy will be on top of this tile.

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


Enemies appear at spawn points.

Move the enemies


After the appearance of the enemy, he must begin to move along the path to the nearest end point. To achieve this, you need to animate enemies. We'll start with a simple, smooth glide from tile to tile, and then make their movement more difficult.

enemies collection


To update the state of our enemies, we will use the same approach that we used in the Object Management tutorial series. Add a Enemy common GameUpdate method that returns information about whether it is alive, which at this stage will always be true. For now, just make it move forward according to the time delta.

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

In addition, we need to maintain a list of living enemies and update all of them, removing from the list of dead enemies. We can put all this code in the Game , but let's isolate it instead and create a EnemyCollection type. This is a serializable class that does not inherit from anything. We give him a general method for adding an enemy and another method for updating the entire collection.

  using System.Collections.Generic;

 [System.Serializable]
 public class EnemyCollection {

 List & lt; Enemy & gt;  enemies = new List & lt; Enemy & gt; ();

 public void Add (Enemy enemy) {
  enemies.Add (enemy);
 }

 public void GameUpdate () {
  for (int i = 0; i & lt; enemies.Count; i ++) {
  if (! enemies [i] .GameUpdate ()) {
  int lastIndex = enemies.Count - 1;
  enemies [i] = enemies [lastIndex];
  enemies.RemoveAt (lastIndex);
  i - = 1;
  }
  }
 }
 }  

Now Game will be enough to create just one such collection, update it in each frame and add created enemies to it. Enemies will be updated immediately after a possible spawning of a new enemy, so that the update takes place instantly.

  EnemyCollection enemies = new EnemyCollection ();

 ...

 void Update () {
  ...
  enemies.GameUpdate ();
 }

 ...

 void SpawnEnemy () {
  ...
  enemies.Add (enemy);
 }  


Enemies are moving forward.

Driving on the way


Enemies are already moving, but not yet following the path. To do this, they need to know where to go next. Therefore, let’s give GameTile the common property getter to get the next tile on the way.

  public GameTile NextTileOnPath = & gt;  nextOnPath;  

Knowing the tile from which to exit, and the tile to which to get, the enemies can determine the starting and ending points to move to one tile. The enemy can interpolate the position between these two points, tracking its movement. After the move is completed, this process is repeated for the next tile. But the paths can change at any time. Instead of determining where to go further in the process of movement, we simply continue to move along the planned route and check it, reaching the next tile.

Let Enemy keep track of both tiles so that it will not be affected by a path change. He will also track positions so that we do not have to receive them every frame, and track the process of moving.

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

Initialize these fields to SpawnOn . The first point is the tile from which the enemy is moving, and the end point is the next tile on the way. This assumes that the next tile exists, unless the enemy was created at the end point, which should be impossible. Then we cache the tile positions and reset the progress. We don’t need to set the enemy’s position here, because his GameUpdate method is called in the same frame.

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

The progress increment will be executed in GameUpdate . Let us add the invariable time delta, so that the enemies move at a speed of one tile per second. When the progress is complete, shift the data so that To becomes From , and the new To is the next tile on the way. Then execute the decrement progress. When the data becomes relevant, we interpolate the position of the enemy between From and To . Since progress is the interpolator, its value is necessarily between 0 and 1, so we can use s Vector3.LerpUnclamped .

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

This forces enemies to follow the path, but will not act upon reaching the end point. Therefore, before changing the positions of From and To , you need to compare the next tile on the way to null .If so, then we reached the end point and the enemy finished the movement. We execute Reclaim for it and return false .

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



Enemies follow the shortest path.

Enemies now move from the center of one tile to another. It is worth considering that they change their state of movement only in tile centers, so they cannot instantly react to changes on the field. This means that sometimes enemies will move through the walls just put. As soon as they began to move towards the cell, nothing would stop them. That is why walls also need real paths.


Enemies react to changing paths.

Edge-Edge Movement


The movement between the centers of the tiles and a sharp change of directions looks normal for an abstract game in which the enemies are moving cubes, but usually smooth movement looks more beautiful. The first step towards its implementation is to move not along the centers, but along the edges of tiles.

The edge point between adjacent tiles can be found by averaging their positions. Instead of calculating it at every step for each enemy, we will only calculate it when changing the path in GameTile.GrowPathTo . Make it available using the ExitPoint property.

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

The only special case is the final cell, the exit point of which will be its center.

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

Change Enemy so that it uses exit points, not tile centers.

  public bool GameUpdate () {
  progress + = Time.deltaTime;
  while (progress & gt; = 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;
 }  


Enemies move between edges.

A side effect of this change is that when enemies turn due to a change in the path, they remain motionless for a second.


When turning the enemies stop.

Orientation


Although the enemies move along the paths until they change their orientation. So that they can look in the direction of movement, they need to know the direction of the path they follow. We will also determine this during the search for ways so that enemies do not have to do this.

We have four directions: north, east, south and west. We assign them an enumeration.

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

Then we give the GameTile property to keep the direction of its path.

  public Direction PathDirection {get;  private set;  }  

Add a direction parameter to GrowTo , which sets the property. As we grow the path from the end to the beginning, the direction will be opposite to where we grow the path.

  public GameTile GrowPathNorth () = & gt;  GrowPathTo (north, Direction.South);

 public GameTile GrowPathEast () = & gt;  GrowPathTo (east, Direction.West);

 public GameTile GrowPathSouth () = & gt;  GrowPathTo (south, Direction.North);

 public GameTile GrowPathWest () = & gt;  GrowPathTo (west, Direction.East);

 GameTile GrowPathTo (GameTile neighbor, Direction direction) {
  ...
  neighbor.PathDirection = direction;
  return
  neighbor.Content.Type! = GameTileContentType.Wall?  neighbor: null;
 }  

We need to transform directions into turns, expressed in the form of quaternions. It would be convenient if we could just call GetRotation for directions, so let's do this by creating an extension method. Add a common static DirectionExtensions method, give it an array to cache the required quaternions, and also GetRotation to return the corresponding direction value. In this case, it makes sense to put the extension class in the same file as the enumeration type.

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

What is the extension method?
The extender method is a static method inside a static class that behaves like instance method of some type. This type can be a class, interface, structure, primitive value, or enumeration. The first argument of the extending method must have the keyword this . It determines the value of the type and instance with which the method will work. This approach means that expanding properties are not possible.

Does this allow me to add methods to anything? Yes, just like you can write any static method whose parameter is any type.

Now we can turn Enemy when spuning and every time we enter a new tile. After updating the data, the From tile gives us a direction.

  public bool GameUpdate () {
  progress + = Time.deltaTime;
  while (progress & gt; = 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;
 }  

Change of direction


Instead of instantly changing the direction, it is better to interpolate the values ​​between turns, in the same way as we interpolated between positions. To move from one orientation to another, we need to know the change in direction that needs to be performed: without turning, turning right, turning left or turning back. Add an enumeration for this, which again can be placed in the same file as Direction , because they are small and closely related.

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

 public enum DirectionChange {
 None, TurnRight, TurnLeft, TurnAround
 }  

Add another extension method, this time GetDirectionChangeTo , which returns the change of direction from the current direction to the next. If the directions are the same, then there is no change. If the next one is greater than the current, then this is a right turn. But since the directions are repeated the same situation will be when the next is three less than the current one. Turning to the left will be the same, only addition and subtraction will be swapped. The only remaining case is a turn back.

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

We only rotate in one dimension, so linear interpolation of the angles will be enough for us. Add another extending method that gets the direction angle in degrees.

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

Now Enemy will have to track the direction, the change of direction and the angles between which you need to perform the interpolation.

  Direction direction;
 DirectionChange directionChange;
 float directionAngleFrom, directionAngleTo;  

SpawnOn becomes more difficult, so let's move the state preparation code to another method. We will set the initial state of the enemy as an introductory state, so we will call it PrepareIntro . In this state, the enemy moves from the center to the edge of his initial tile, so the change of direction does not occur. The From and To angles are the same.

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

At this stage, we create something like a small finite state machine. Not to complicate the GameUpdate , move the state change code to the new PrepareNextState method. Leaving only the From and To tiles change, because we use them here to check if the enemy has finished the path.

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

When switching to a new state, you always need to change positions, find a change of direction, update the current direction and shift the angle To to From . Turning we no longer ask.

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

Other actions depend on the change of direction. Let's add a method for each option. In case we move forward, the angle To coincides with the direction of the current cell path. In addition, we need to set a turn for the enemy to look straight ahead.

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

In the case of a turn, we do not turn instantly. We need to interpolate to another angle: 90 ° more to turn right, 90 ° less to turn left, and 180 ° more to turn back. To avoid turning in the wrong direction due to a change in the angle values ​​from 359 ° to 0 °, the angle To should be indicated relative to the current direction. We do not need to worry that the angle will become less than 0 ° or more than 360 °, because Quaternion.Euler can handle this.

  void PrepareTurnRight () {
  directionAngleTo = directionAngleFrom + 90f;
 }

 void PrepareTurnLeft () {
  directionAngleTo = directionAngleFrom - 90f;
 }

 void PrepareTurnAround () {
  directionAngleTo = directionAngleFrom + 180f;
 }  

At the end of PrepareNextState , we can use switch to change direction to decide which of the four methods to call.

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

Now at the end of GameUpdate we need to check if the change of direction has occurred. If yes, then interpolate between two angles and set the 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;
 }  


Enemies are turning.

Curving


We can improve the movement by forcing the enemies to move along the curve when turning. Instead of walking from edge to edge of tiles, let them walk in a quarter of a circle. The center of this circle lies in the corner common for the From and To tiles, on the same edge where the enemy entered the From tile. < br/>

A quarter circle rotation to the right.

We can accomplish this by moving the enemy in an arc using trigonometry, while at the same time turning it. But this can be simplified by using only the turn, temporarily moving the local origin of the enemy to the center of the circle. To do this, we need to change the position of the enemy model, so we give Enemy a link to this model, accessible via the configuration field.

  [SerializeField]
 Transform model = default;  


Enemy with reference to the model.

When preparing to move forward or turn back, the model should move to the standard position, to the local origin of the enemy. Otherwise, the model must be shifted by half of the unit of measurement - the radius of the turn circle, away from the turning point.

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

Now the enemy must be moved to the turning point. To do this, you also need to move it to half the unit, but the exact offset depends on the direction. Let's add to the Direction for this the auxiliary extending method 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];
 }  

Add the corresponding vector when turning right or left.

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

And when you turn back, the position should be the usual starting point.

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

In addition, we can use the GameTile.GrowPathTo half of the vector when calculating the exit point so that we do not need access to two tile positions.

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

Now, when we change direction, we should not interpol the position in Enemy.GameUpdate , because the rotation is in motion.

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


Enemies smoothly round corners.

Constant Speed ​​


Up to this point, the speed of the enemies has always been equal to one tile per second, regardless of how they move inside the tile. But the distance they cover depends on their state, so their speed, expressed in units per second, varies. For this speed to be constant, we need to change the speed of progress depending on the state. Therefore, add the progress multiplier field and use it to scale the delta in GameUpdate .

  float progress, progressFactor;

 ...

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

But if progress changes depending on the state, the remaining progress value cannot be used directly for the next state. Therefore, before preparing for a new state, we need to normalize progress and apply the new multiplier in a new state.

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

Moving forward does not require changes, so it uses factor 1. Turning right or left, the enemy passes a quarter of a circle with a radius of ½, so the distance to be covered is π. progress is equal to one divided by this value. Turning back should not take too much time, so double the progress so that it takes half a second. Finally, the introductory movement covers only half of the tile, so in order to maintain a constant speed, its progress must also be doubled.

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

Why is the distance 1/4 * pi?
The circumference is 2π times the radius. Turning right or left covers only a quarter of this length, and the radius is ½, so the distance is ½π × ½.

Final State


Since we have an introductory state, let's add and complete. At the moment, the enemies disappear immediately after reaching the end point, but let's postpone their disappearance until they reach the center of the final tile. Create a PrepareOutro method for this, set the motion forward, but only to the center of the tile with doubled progress to maintain a constant speed.

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

To prevent GameUpdate from destroying the enemy too soon, remove the tile shift from it. They will now be engaged in PrepareNextState . Thus, checking for null will return true only after the end of the terminating state.

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

In PrepareNextState , we start by shifting the tiles.Then, after setting the From position, but before setting the To position, we will check if the To tile is null . If so, prepare the final state and skip the rest of the method.

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


Enemies with constant speed and final state.

Variability of enemies


We have a stream of enemies, and they are all the same cube, moving with the same speed. The result is more like a long snake than individual enemies. Let's make them more different by randomizing their size, offset and speed.

Float Value Range


We will change the parameters of the enemies, randomly selecting their characteristics from the range of values. Here the FloatRange structure that we created in the article Object Management, Configuring Shapes will be useful. , so let's copy it. The only changes were the addition of a constructor with a single parameter and the opening of access to the minimum and maximum using readonly properties so that the interval was immutable.
  using UnityEngine;

 [System.Serializable]
 public struct FloatRange {

 [SerializeField]
 float min, max;

 public float Min = & gt;  min;

 public float max = & gt;  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 & lt;  min?  min: max;
 }
 }  

Also copy the attribute assigned to it to limit its interval.

  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 & lt;  min?  min: max;
 }
 }  

We only need to render the slider, so copy the FloatRangeSliderDrawer into the Editor folder.

  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 & lt; limit.Min) {
  minValue = limit.Min;
  }
  if (maxValue & lt; minValue) {
  maxValue = minValue;
  }
  else if (maxValue & gt; limit.Max) {
  maxValue = limit.Max;
  }
  minProperty.floatValue = minValue;
  maxProperty.floatValue = maxValue;

  EditorGUI.EndProperty ();
  EditorGUI.indentLevel = originalIndentLevel;
 }
 }  

Model Scale


We begin with a change in the scale of the enemy. Add a scale option to EnemyFactory . The range of scales should not be too large, but sufficient to create miniature and gigantic varieties of enemies. Anything within 0.5–2 with a standard value of 1. We will choose a random scale in this interval in Get and transfer it to the enemy through the new Initialize method.

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

The Enemy.Initialize method simply sets the scale of its model that is the same for all measurements.

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

inspector

scene

Scale interval from 0.5 to 1.5.

Path Offset


In order to destroy the homogeneity of the stream of enemies even more, we can change their relative position inside the tiles. They move forward, so the shift in this direction only changes the timing of their movement, which is not very noticeable. Therefore, we will shift them sideways, away from the ideal path passing through the centers of the tiles. Add the EnemyFactory path offset interval and pass the random offset to the Initialize method. The offset may be negative or positive, but never more than ½, because it would move the enemy to the next tile. In addition, we don’t want the enemies to go beyond the tiles that they follow, so the interval will actually be less, for example, 0.4, but the true limits depend on the size of the enemy.

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

Since the path offset affects the path traveled, Enemy needs to be tracked.

  float pathOffset;

 ...

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

When driving exactly straight (during introductory, final, or normal forward movement) we simply apply the offset directly to the model. The same thing happens when you turn back. With right or left turn, we already shift the model, which becomes relative to the displacement of the path.

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

Since the offset of the path when turning changes the radius, we need to change the process of calculating the progress multiplier. The path offset must be subtracted from ½ to get the turn radius to the right, and added in case of a turn to the left.

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

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

We also get a turning radius when turning 180 °. In this case, we cover half the circle with a radius equal to the displacement of the path, so the distance is π times the displacement. However, this does not work when the displacement is zero, and at small displacements the turns turn out to be too fast. To avoid instant turns, we can force a minimum radius to calculate the speed, say, 0.2.

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

inspector


Path offset in the range of −0.25–0.25.

Notice that now enemies never change their relative path offset, even when turning. Therefore, the total path length of each enemy has its own.

In order for the enemies not to go to neighboring tiles, one must also take into account their maximum possible scale. I just limited the size to a maximum value of 1, so the maximum allowable offset for a cube is 0.25. If the maximum size were 1.5, then the maximum displacement would have been reduced to 0.125.

Speed ​​


The last thing we randomize is the speed of the enemies. Add one more interval for it to EnemyFactory and we will pass the value to the created instance of the enemy. Make it the second argument to the Initialize method. Enemies should not be too slow or fast so that the game does not become trivially simple or impossibly difficult. Let's limit the interval to 0.2–5. The speed is expressed in units per second, which corresponds to tiles per second only when moving forward.

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

Now Enemy should track and speed.

  float speed;

 ...

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

When we did not explicitly set the speed, we simply always used the value 1. Now we just need to create a dependence of the progress factor on the speed.

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



Speed ​​in the range of 0.75–1.25.

So, we got a beautiful stream of enemies moving to the end point. In the next tutorial we will learn how to deal with them. Want to know when it comes out? Follow my page on Patreon !

repository

Article to PDF

Source text: [Translation] Creating a Tower Defense Game in Unity: Enemies