📜 ⬆️ ⬇️

Creating a Tower Defense Game in Unity: Enemies

[ First part: tiles and search path ]


This is the second part of the tutorial dedicated to the simple tower defense game. 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 their way to the end point.

Creation points (spawns) of enemies


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

Tile content


The spawn 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.


Spawn points configuration.

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 or disable spawn points


The method for switching the state of the spawn point, like other switching methods, we will add to the GameBoard . But spawn points do not affect the search for the 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 field of play 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 state of the spawn point and prevent the removal of the last spawn point.

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

The Initialize method should now set a spawn point to create the initial correct field state. Let's just turn on the first tile, which is in the bottom 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.

Getting access to spawn points


Paul is engaged in all his tiles, but enemies are not his responsibility. We will do this so that we can access its spawn points through the common method GetSpawnPoint 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 => spawnPoints.Count; 

Spawning 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 to the factory that we already have, so let's put the code for it in the common GameObjectFactory base class. We need only one method CreateGameObjectInstance with a common prefab parameter that creates and returns an instance, as well as controls the entire scene. Let's make the protected method, 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<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; } } 

Let's change GameTileContentFactory so that it GameTileContentFactory 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 type of EnemyFactory , which creates an instance of one prefab of Enemy 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 OriginFactory { get => 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 provide its convenient support, we will use the root object to which the Enemy component is attached to the hierarchy of the enemy's prefab.


Prefab root.

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


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 us make it a child of the model root and assign the value on Y to 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 hierarchy of the prefab of the enemy.

Create a factory of enemies and assign it a prefab.


Asset factory.

Placing 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. The 0.1–10 interval with a value of 1 by default seems acceptable.

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


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

We will track the progress of spacing in Update , increasing it by the speed multiplied by the time delta. If the value of prggress 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 >= 1f) { spawnProgress -= 1f; SpawnEnemy(); } } 

Isn’t it necessary to update progress in FixedUpdate?
Yes, it is possible, but for the game of tower defense genre such precise timings are 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 SpawnOn that he can position himself correctly.

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

For now, all SpawnOn has to do is set its own position equal to the center of the tile. Since the prefab model is positioned 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.

Moving enemies


After the appearance of the enemy, he should start moving along the path to the nearest end point. To achieve this, you need to animate enemies. We start with a simple, smooth slide 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 an 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 Game , but let's isolate it instead and create an 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<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; } } } } 

Now the 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, we give GameTile common getter property to get the next tile on the path.

  public GameTile NextTileOnPath => 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, having reached the next tile.

Let Enemy keep track of both tiles so that it will not be affected by a change in the path. 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 in 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 do not need to set the position of the enemy here, because his GameUpdate method GameUpdate 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 completed, shift the data so that To becomes the value From , and the new To 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 >= 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 From and To , you need to compare the next tile on the way with 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 >= 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-to-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 the 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 calculate it only when the path changes 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 is 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 >= 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 the 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. Let's set for them transfer.

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

We need to transform directions into turns, expressed in the form of quaternions. It would be convenient if we could just call GetRotation for direction, so let's do this by creating an extension method. Let's add the general static DirectionExtensions method, give it an array for caching the necessary quaternions, and also the GetRotation method for returning 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?
An extension method is a static method inside a static class that behaves like an instance method of some type. This type can be a class, interface, structure, primitive value, or enumeration. The first argument of the expanding 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 while spawning and every time we enter a new tile. After updating the data, the From Tile gives us 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; } 

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 one, 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 extension method that gets the direction angle in degrees.

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

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

  Direction direction; DirectionChange directionChange; float directionAngleFrom, directionAngleTo; 

SpawnOnit gets harder, so let's move the state preparation code to another method. We will assign 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 angles Fromand Tothe 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. To keep things simple GameUpdate, move the state code to a new method PrepareNextState. We leave only the changes of the tiles Fromand To, because we use them here to check whether the enemy has finished the path.

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

When moving to a new state, you always need to change positions, find a change of direction, update the current direction and shift the angle Toto From. Rotation, 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 Tocoincides 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 Toshould 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 it Quaternion.Eulercan cope with this.

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

At the end, PrepareNextStatewe can use switchto change directions 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 GameUpdatewe need to check if the change of direction has occurred. If yes, then interpolate between two angles and set a turn.

  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.

Curve movement


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 to tiles Fromand To, on the same edge, along which the enemy entered the tile From.


A quarter turn to turn 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 a Enemylink to this model, accessible through 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 should 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 in Directionfor this auxiliary extension 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]; } 

We 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, when calculating the exit point, we can use GameTile.GrowPathTohalf of the vector so that we do not need access to two tile positions.

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

Now, when changing direction, we should not interpolate the position in Enemy.GameUpdate, because the movement is engaged in turning.

  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. In order for this speed to be constant, we need to change the speed of progress depending on the state. Therefore, add the progress factor 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 a new factor in a new state.

  public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 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 π. progressequals 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 to maintain a constant speed, its progress also needs to 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 the distance is 1/4 * pi?
2π, . , ½, ½π × ½.

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. Let's create a method for this PrepareOutro, set forward movement, 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; } 

In order GameUpdatenot to destroy the enemy too soon, we will remove the tile shift from it. He will now be engaged PrepareNextState. Thus, checking for nullreturns trueonly after the end of the terminating state.

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

In PrepareNextStatewe will start with the shift of the tiles. Then after setting the position From, but before setting the position, Towe will check whether the tile is equal to the Tovalue 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.

Enemy variability


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. The structure FloatRangewe created in the article Object Management, Configuring Shapes will be useful here , so let's copy it. The only changes were the addition of a constructor with one 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 => 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; } } 

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

We only need to visualize the slider, so copy it FloatRangeSliderDrawerto 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 < 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; } } 

Model scale


We begin with a change in the scale of the enemy. Add to EnemyFactorythe scale option. 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 Getand transfer it to the enemy through a new method 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; } 

The method Enemy.Initializesimply sets the scale of its model that is the same for all dimensions.

  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 a EnemyFactorypath to the interval offsets and pass the random offset to the method Initialize. The offset may be negative or positive, but never more than ½, because it would move the enemy to the next tile. In addition, we do not want the enemies to go beyond the tiles on which they are moving, therefore, in fact, the interval will 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, Enemyit is necessary to track it.

  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. When 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 displacement 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 < 0f ? 180f : -180f); model.localPosition = new Vector3(pathOffset, 0f); transform.localPosition = positionFrom; progressFactor = 1f / (Mathf.PI * Mathf.Max(Mathf.Abs(pathOffset), 0.2f)); } 

inspector


The path offset is in the interval −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 equal to 1.5, then the maximum offset should have been reduced to 0.125.

Speed


The last thing we randomize is the speed of the enemies. Add one more interval for it in EnemyFactoryand we will transfer the value to the created instance of the enemy. We make it the second argument of the method Initialize. 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. 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 Enemymust 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 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; } 



The speed is 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 in PDF

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


All Articles