Creando Tower Defense en Unity: Enemies

[ Primera parte: azulejos y encontrar el camino ]

  • Colocación de puntos de creación enemigos.
  • La aparición de enemigos y su movimiento por el campo.
  • Creando movimiento suave a una velocidad constante.
  • Cambia el tamaño, la velocidad y la ubicación de los enemigos.

Esta es la segunda parte de un tutorial sobre un simple juego de defensa de la torre . Examina el proceso de creación de enemigos y su movimiento hasta el punto final más cercano.

Este tutorial está hecho en Unity 2018.3.0f2.


Enemigos en el camino hacia el punto final.

Puntos de creación de enemigos (engendro)


Antes de comenzar a crear enemigos, debemos decidir dónde colocarlos en el campo. Para hacer esto, crearemos puntos de generación.

Contenido del azulejo


Un punto de generación es otro tipo de contenido de mosaico, así que agregue una entrada en GameTileContentType .

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

Y luego crea un prefabricado para visualizarlo. Un duplicado del prefabricado del punto de partida es bastante adecuado para nosotros, solo cambie su tipo de contenido y dele otro material. Lo hice naranja.


Configuración del punto de generación.

Agregue soporte de punto de generación a la fábrica de contenido y dele un enlace al prefabricado.

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


Fábrica con soporte para puntos de generación.

Habilitar o deshabilitar puntos de generación


El método para cambiar el estado del punto de GameBoard , como otros métodos de cambio, lo agregaremos a GameBoard . Pero los puntos de generación no afectan la búsqueda de la ruta, por lo que después del cambio no necesitamos buscar nuevas rutas.

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

El juego solo tiene sentido si tenemos enemigos, y ellos necesitan puntos de generación. Por lo tanto, el campo del juego debe contener al menos un punto de generación. También necesitaremos acceso a los puntos de generación en el futuro, cuando agreguemos enemigos, así que usemos la lista para rastrear todas las fichas con estos puntos. Actualizaremos la lista al cambiar el estado del punto de generación y evitaremos la eliminación del último punto de generación.

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

El método Initialize ahora debería establecer el punto de generación para crear el estado inicial correcto del campo. Incluyamos el primer mosaico, que está en la esquina inferior izquierda.

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

Haremos que el toque alternativo ahora cambie el estado de los puntos de generación, pero cuando mantiene presionada la tecla Shift izquierda (el método Input.GetKey comprueba la pulsación de tecla), el estado del punto final cambiará

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


Campo con puntos de desove.

Obtenga acceso a los puntos de generación


El campo trata con todas sus fichas, pero los enemigos no son su responsabilidad. Haremos posible acceder a sus puntos de GetSpawnPoint través del método GetSpawnPoint común con un parámetro de índice.

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

Para saber qué índices son correctos, se necesita información sobre el número de puntos de generación, por lo que lo generalizaremos utilizando la propiedad getter general.

  public int SpawnPointCount => spawnPoints.Count; 

Engendro enemigo


Engendrar un enemigo es algo similar a crear el contenido de una ficha. Creamos una instancia prefabricada a través de la fábrica, que luego colocamos en el campo.

Fábricas


Crearemos una fábrica para enemigos que pondrá todo lo que crea en su propio escenario. Esta funcionalidad es común con la fábrica que ya tenemos, así que pongamos el código en la clase base común GameObjectFactory . Solo necesitaremos un método CreateGameObjectInstance con un parámetro prefabricado común, que crea y devuelve una instancia, y también administra toda la escena. Creamos el método protected , es decir, estará disponible solo para la clase y todos los tipos que hereden de él. Eso es todo lo que hace la clase; no está destinado a ser utilizado como una fábrica completamente funcional. Por lo tanto, lo marcamos como abstract , lo que no nos permitirá crear instancias de sus objetos.

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

Cambie GameTileContentFactory para que GameTileContentFactory este tipo de fábrica y use CreateGameObjectInstance en su método Get , y luego elimine el código de control de escena.

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

Después de eso, cree un nuevo tipo EnemyFactory que cree una instancia de una prefabricada Enemy usando el método Get junto con el método Reclaim acompaña.

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

El nuevo tipo Enemy inicialmente solo tenía que hacer un seguimiento de su fábrica original.

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

Prefabricados


Los enemigos necesitan visualización, que puede ser cualquier cosa: un robot, una araña, un fantasma, algo más simple, por ejemplo, un cubo, que usamos. Pero en general, el enemigo tiene un modelo 3D de cualquier complejidad. Para garantizar su soporte conveniente, utilizaremos el objeto raíz para la jerarquía prefabricada enemiga, a la que solo se adjunta el componente Enemy .


Raíz prefabricada

Creemos este objeto como único elemento hijo, que será la raíz del modelo. Debe tener valores de unidad de transformación.


La raíz del modelo.

La tarea de esta raíz del modelo es posicionar el modelo 3D en relación con el punto de origen local de las coordenadas del enemigo para que lo considere un punto de referencia sobre el cual el enemigo está parado o colgando. En nuestro caso, el modelo será un cubo estándar de tamaño medio, al que le daré un color azul oscuro. Lo hacemos un hijo de la raíz del modelo y establecemos la posición Y en 0.25 para que quede en el suelo.


Modelo de cubo

Por lo tanto, el prefabricado del enemigo consta de tres objetos anidados: la raíz prefabricada, la raíz del modelo y el cubo. Puede parecer un fracaso para un cubo simple, pero dicho sistema le permite moverse y animar a cualquier enemigo sin preocuparse por sus características.


La jerarquía prefabricada del enemigo.

Vamos a crear una fábrica enemiga y asignarle una casa prefabricada.


Fábrica de activos.

Colocando enemigos en el campo


Para poner enemigos en el campo, el Game debe recibir un enlace a la fábrica de enemigos. Como necesitamos muchos enemigos, agregaremos una opción de configuración para ajustar la velocidad de desove, expresada en el número de enemigos por segundo. Un rango aceptable es 0.1-10 con un valor predeterminado de 1.

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


Juego con una fábrica enemiga y velocidad de desove 4.

Rastrearemos la progresión del desove en Update , incrementándola por la velocidad multiplicada por el tiempo delta. Si el valor de progreso excede 1, entonces lo disminuimos y engendramos al enemigo usando el nuevo método SpawnEnemy . Continuamos haciendo esto hasta que el progreso exceda 1 en caso de que la velocidad sea demasiado alta y el tiempo de cuadro sea muy largo, de modo que no se creen varios enemigos al mismo tiempo.

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

¿No es necesario actualizar el progreso en FixedUpdate?
Sí, es posible, pero estos tiempos precisos no son necesarios para el juego de defensa de la torre. Simplemente actualizaremos el estado del juego en cada cuadro y haremos que funcione lo suficientemente bien para cualquier delta de tiempo.

Deja que SpawnEnemy obtenga un punto de SpawnEnemy aleatorio del campo y cree un enemigo en esta casilla. Le daremos a Enemy el método SpawnOn para SpawnOn correctamente.

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

Por ahora, todo lo que SpawnOn tiene que hacer es establecer su propia posición igual al centro del mosaico. Dado que el modelo prefabricado está colocado correctamente, el cubo enemigo estará en la parte superior de este mosaico.

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


Los enemigos aparecen en los puntos de generación.

Enemigos en movimiento


Después de que aparezca el enemigo, debe comenzar a moverse por el camino hasta el punto final más cercano. Para lograr esto, necesitas animar a los enemigos. Comenzamos con un simple deslizamiento suave de mosaico a mosaico, y luego hacemos que su movimiento sea más difícil.

Colección de enemigos


Para actualizar el estado de los enemigos, utilizaremos el mismo enfoque que se utilizó en la serie de tutoriales de gestión de objetos . GameUpdate Enemy método general GameUpdate , que devuelve información sobre si está vivo, que en esta etapa siempre será cierto. Por ahora, solo haz que avance de acuerdo con el delta del tiempo.

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

Además, debemos mantener una lista de enemigos vivos y actualizarlos a todos, eliminándolos de la lista de enemigos muertos. Podemos poner todo este código en un Game , pero en su lugar, aislarlo y crear un tipo EnemyCollection . Esta es una clase serializable que no hereda de nada. Le damos un método general para agregar un enemigo y otro método para actualizar toda la colección.

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

Ahora el Game será suficiente para crear solo una de esas colecciones, en cada cuadro, actualícela y agregue enemigos creados. Actualizaremos a los enemigos inmediatamente después del posible desove de un nuevo enemigo para que la actualización tenga lugar al instante.

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


Los enemigos están avanzando.

Movimiento en el camino


Los enemigos ya se están moviendo, pero hasta ahora no siguen el camino. Para hacer esto, necesitan saber a dónde ir después. Por lo tanto, demos a GameTile propiedad getter común para obtener el siguiente mosaico en el camino.

  public GameTile NextTileOnPath => nextOnPath; 

Conociendo el mosaico del que desea salir, y el mosaico en el que necesita llegar, los enemigos pueden determinar los puntos de inicio y final para mover un mosaico. El enemigo puede interpolar la posición entre estos dos puntos, siguiendo su movimiento. Una vez que se completa el movimiento, este proceso se repite para el siguiente mosaico. Pero los caminos pueden cambiar en cualquier momento. En lugar de determinar dónde avanzar más en el proceso de movimiento, simplemente continuamos moviéndonos a lo largo de la ruta planificada y verificamos, llegando al siguiente mosaico.

Deja que el Enemy rastree ambas fichas para que no se vea afectado por un cambio en el camino. También hará un seguimiento de las posiciones para que no tengamos que recibirlas en cada cuadro, y el proceso de movimiento.

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

Inicialice estos campos en SpawnOn . El primer punto es el mosaico desde el cual se mueve el enemigo, y el punto final es el siguiente mosaico en el camino. Esto supone que existe el siguiente mosaico, a menos que el enemigo se haya creado en el punto final, lo que debería ser imposible. Luego almacenamos en caché las posiciones de los mosaicos y reiniciamos el progreso. No necesitamos establecer la posición del enemigo aquí, porque su método GameUpdate llama en el mismo marco.

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

El incremento de progreso se realizará en GameUpdate . Agreguemos un delta de tiempo constante para que los enemigos se muevan a una velocidad de una ficha por segundo. Cuando se completa el progreso, cambiamos los datos para que To convierta en el valor From , y el nuevo To sea ​​el siguiente mosaico en la ruta. Luego disminuimos el progreso. Cuando los datos se vuelven relevantes, interpolamos la posición del enemigo entre From y To . Como el interpolador es progreso, su valor está necesariamente en el rango de 0 y 1, por lo que podemos usar 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; } 

Esto obliga a los enemigos a seguir el camino, pero no actuará cuando llegue al punto final. Por lo tanto, antes de cambiar las posiciones de From y To , debe comparar el siguiente mosaico en la ruta con null . Si es así, hemos llegado al punto final y el enemigo ha terminado el movimiento. Ejecutamos Reclaim para ello y devolvemos 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; } 



Los enemigos siguen el camino más corto.

Ahora los enemigos se mueven del centro de una ficha a otra. Vale la pena considerar que cambian su estado de movimiento solo en los centros de las fichas, por lo tanto, no pueden responder de inmediato a los cambios en el campo. Esto significa que a veces los enemigos se moverán a través de los muros que acabas de establecer. Una vez que comenzaron a moverse hacia la celda, nada los detendrá. Es por eso que las paredes también necesitan caminos reales.


Los enemigos reaccionan a los caminos cambiantes.

Movimiento de borde a borde


El movimiento entre los centros de las fichas y un cambio brusco de dirección parece normal para un juego abstracto en el que los enemigos mueven cubos, pero generalmente el movimiento suave se ve más hermoso. El primer paso para su implementación no es moverse a lo largo de los centros, sino a lo largo de los bordes de los mosaicos.

El punto de borde entre las fichas adyacentes se puede encontrar promediando sus posiciones. En lugar de calcularlo en cada paso para cada enemigo, solo lo calcularemos al cambiar la ruta en GameTile.GrowPathTo . ExitPoint disposición utilizando la propiedad 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; } 

El único caso especial es la celda final, cuyo punto de salida será su centro.

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

Cambia al Enemy para que use puntos de salida, no centros de fichas.

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


Los enemigos se mueven entre los bordes.

Un efecto secundario de este cambio es que cuando los enemigos giran debido a un cambio en el camino, permanecen inmóviles por un segundo.


Al girar, los enemigos se detienen.

Orientación


Aunque los enemigos se mueven por los caminos hasta que cambian de orientación. Para que puedan mirar en la dirección del movimiento, necesitan saber la dirección del camino que están siguiendo. También determinaremos esto durante la búsqueda de formas, para que los enemigos no tengan que hacerlo.

Tenemos cuatro direcciones: norte, este, sur y oeste. Vamos a enumerarlos.

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

Luego le damos a la propiedad GameTile para almacenar la dirección de su ruta.

  public Direction PathDirection { get; private set; } 

Agregue un parámetro de dirección a GrowTo , que establece la propiedad. Dado que estamos cultivando un camino desde el final hasta el principio, la dirección será opuesta a donde estamos creciendo el camino.

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

Necesitamos convertir las direcciones en giros expresados ​​como cuaterniones. Sería conveniente si pudiéramos llamar a GetRotation para la dirección, así que hagamos esto creando un método de extensión. Agregue el método estático general DirectionExtensions , dele una matriz para almacenar en caché los cuaterniones necesarios, así como el método GetRotation para devolver el valor de dirección correspondiente. En este caso, tiene sentido colocar la clase de extensión en el mismo archivo que el tipo de enumeración.

 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é es un método de extensión?
Un método de extensión es un método estático dentro de una clase estática que se comporta como un método de instancia de algún tipo. Este tipo puede ser una clase, interfaz, estructura, valor primitivo o enumeración. El primer argumento para el método de extensión debe tener la this . Define el valor del tipo y la instancia con la que funcionará el método. Este enfoque significa que las propiedades de expansión no son posibles.

¿Esto le permite agregar métodos a cualquier cosa? Sí, así como puede escribir cualquier método estático cuyo parámetro sea de cualquier tipo.

Ahora podemos rotar Enemy al desovar y cada vez que ingresamos a una nueva casilla. Después de actualizar los datos, el mosaico From nos da la dirección.

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

Cambio de dirección


En lugar de cambiar de dirección instantáneamente, es mejor interpolar valores entre giros, de forma similar a como interpolamos entre posiciones. Para pasar de una orientación a otra, necesitamos saber el cambio de dirección que debe hacerse: sin girar, girar a la derecha, girar a la izquierda o retroceder. Agregamos para esto una enumeración, que nuevamente se puede colocar en el mismo archivo que la Direction , porque son pequeños y están estrechamente relacionados.

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

Agregue otro método de extensión, esta vez GetDirectionChangeTo , que devuelve un cambio de dirección de la dirección actual a la siguiente. Si las direcciones coinciden, entonces no hay cambio. Si el siguiente es más que el actual, entonces este es un giro a la derecha. Pero dado que las instrucciones se repiten, la misma situación será cuando la próxima sea tres menos que la actual. Con un giro a la izquierda será lo mismo, solo la suma y la resta cambiarán de lugar. El único caso restante es un retroceso.

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

Hacemos una rotación en una sola dimensión, por lo que la interpolación lineal de ángulos será suficiente para nosotros. Agregue otro método de expansión que obtenga el ángulo de dirección en grados.

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

Ahora tiene Enemyque rastrear la dirección, el cambio de dirección y los ángulos entre los cuales necesita realizar la interpolación.

  Direction direction; DirectionChange directionChange; float directionAngleFrom, directionAngleTo; 

SpawnOncada vez más difícil, así que pasemos el código de preparación del estado a otro método. Designaremos el estado inicial del enemigo como un estado introductorio, por lo que lo llamaremos PrepareIntro. En este estado, el enemigo se mueve desde el centro hasta el borde de su mosaico inicial, por lo que no hay cambio de dirección. Los ángulos Fromy Tola misma.

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

En esta etapa, creamos algo así como una pequeña máquina de estados. Para simplificar las cosas GameUpdate, mueva el código de estado a un nuevo método PrepareNextState. Dejaremos solo los cambios de las fichas Fromy To, porque los usamos aquí para verificar si el enemigo ha terminado el camino.

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

Al hacer la transición a un nuevo estado, siempre necesita cambiar las posiciones, encontrar un cambio de dirección, actualizar la dirección actual y cambiar el ángulo Toa From. Ya no establecemos un giro.

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

Otras acciones dependen de un cambio de dirección. Agreguemos un método para cada opción. En caso de que avancemos, el ángulo Tocoincide con la dirección de la ruta de la celda actual. Además, necesitamos establecer la rotación para que el enemigo mire hacia adelante.

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

En el caso de un giro, no giramos al instante. Necesitamos interpolar a un ángulo diferente: 90 ° más para girar a la derecha, 90 ° menos para girar a la izquierda y 180 ° más para retroceder. Para evitar girar en la dirección incorrecta debido a un cambio en los valores de ángulo de 359 ° a 0 °, el ángulo Todebe indicarse en relación con la dirección actual. No debemos preocuparnos de que el ángulo sea inferior a 0 ° o superior a 360 °, porque podemos Quaternion.Eulermanejarlo.

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

Al final, PrepareNextStatepodemos usar switchpara cambiar las direcciones para decidir a cuál de los cuatro métodos llamar.

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

Ahora al final GameUpdatenecesitamos verificar si la dirección ha cambiado. Si es así, interpolar entre las dos esquinas y establecer la rotación.

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


Los enemigos están cambiando.

Movimiento de la curva


Podemos mejorar el movimiento haciendo que los enemigos se muevan a lo largo de una curva al girar. En lugar de caminar de borde a borde de los azulejos, déjelos caminar un cuarto de círculo. El centro de este círculo se encuentra en una esquina común a las fichas Fromy Toen el mismo borde a lo largo del cual el enemigo entró en la ficha From.


Una rotación de un cuarto de círculo para girar a la derecha.

Podemos darnos cuenta de esto moviendo al enemigo en un arco usando trigonometría, al mismo tiempo que lo giramos. Pero esto se puede simplificar usando solo rotación, moviendo temporalmente el origen local de las coordenadas del enemigo al centro del círculo. Para hacer esto, necesitamos cambiar la posición del modelo enemigo, por lo que le daremos un Enemyenlace a este modelo, accesible a través del campo de configuración.

  [SerializeField] Transform model = default; 


Enemigo con referencia al modelo.

En preparación para avanzar o retroceder, el modelo debe moverse a la posición estándar, al origen local de las coordenadas del enemigo. De lo contrario, el modelo debe desplazarse a la mitad de la unidad de medida: el radio del círculo de rotación, lejos del punto de giro.

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

Ahora el enemigo mismo necesita ser trasladado al punto de inflexión. Para hacer esto, también debe moverse la mitad de la unidad de medida, pero el desplazamiento exacto depende de la dirección. Agreguemos Directionun método de extensión auxiliar a esto 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]; } 

Agregue el vector correspondiente al girar a la derecha o izquierda.

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

Y al regresar, la posición debe ser el punto de partida habitual.

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

Además, al calcular el punto de salida, podemos usar la GameTile.GrowPathTomitad del vector para que no necesitemos acceso a las dos posiciones de los mosaicos.

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

Ahora, al cambiar de dirección, no tenemos que interpolar la posición Enemy.GameUpdate, porque la rotación está involucrada en el movimiento.

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


Los enemigos se doblan suavemente alrededor de las esquinas.

Velocidad constante


Hasta este punto, la velocidad de los enemigos siempre ha sido igual a una ficha por segundo, independientemente de cómo se muevan dentro de la ficha. Pero la distancia que recorren depende de su condición, por lo que su velocidad, expresada en unidades por segundo, varía. Para que esta velocidad sea constante, necesitamos cambiar la velocidad del progreso dependiendo del estado. Por lo tanto, agregue el campo multiplicador de progreso y úselo para escalar el delta GameUpdate.

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

Pero si el progreso cambia según el estado, el valor de progreso restante no se puede usar directamente para el siguiente estado. Por lo tanto, antes de prepararnos para un nuevo estado, necesitamos normalizar el progreso y aplicar el nuevo multiplicador ya en un nuevo estado.

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

Avanzar no requiere cambios, por lo tanto, utiliza un factor de 1. Al girar a la derecha o izquierda, el enemigo pasa un cuarto de círculo con un radio de ½, por lo que la distancia recorrida es ¼π. progressigual a uno dividido por este valor. Retroceder no debería llevar demasiado tiempo, así que duplica el progreso para que tarde medio segundo. Finalmente, el movimiento introductorio cubre solo la mitad de la loseta, por lo tanto, para mantener una velocidad constante, también es necesario duplicar su progreso.

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

¿Por qué la distancia es igual a 1/4 * pi?
2π, . , ½, ½π × ½.

Estado final


Como tenemos un estado introductorio, agreguemos uno final. Actualmente, los enemigos están desapareciendo inmediatamente después de llegar al punto final, pero pospongamos su desaparición hasta que lleguen al centro de la casilla final. Creemos un método para esto PrepareOutro, establezca el movimiento hacia adelante, pero solo en el centro del mosaico con el doble de progreso para mantener una velocidad constante.

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

Para GameUpdateno destruir al enemigo demasiado pronto, eliminaremos el cambio de fichas. Lo hará ahora PrepareNextState. Por lo tanto, buscar nulldevoluciones truesolo después del final del estado final.

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

En PrepareNextStatecomenzaremos con el cambio de fichas. Luego, después de establecer la posición From, pero antes de establecer la posición, Tocomprobaremos si el mosaico es igual al Tovalor null. Si es así, prepare el estado final y omita el resto del método.

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


Enemigos con velocidad constante y estado final.

Variabilidad del enemigo


Tenemos una corriente de enemigos, y todos son el mismo cubo, moviéndose a la misma velocidad. El resultado es más como una serpiente larga que enemigos individuales. Hagámoslos más diferentes aleatorizando su tamaño, desplazamiento y velocidad.

Rango de valor flotante


Cambiaremos los parámetros de los enemigos, eligiendo aleatoriamente sus características del rango de valores. La estructura FloatRangeque creamos en el artículo Gestión de objetos, Configuración de formas será útil aquí , así que copiémosla. Los únicos cambios fueron agregar un constructor con un parámetro y abrir el acceso al mínimo y al máximo utilizando propiedades de solo lectura, de modo que el intervalo no se pudiera cambiar.

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

También copiamos el conjunto de atributos para limitar su intervalo.

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

Solo necesitamos la visualización del control deslizante, así que cópielo FloatRangeSliderDraweren la carpeta 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; } } 

Escala modelo


Comenzaremos cambiando la escala del enemigo. Agregue EnemyFactoryla configuración de escala a la opción. El intervalo de escala no debe ser demasiado grande, sino suficiente para crear variedades de enemigos en miniatura y gigantescas. Cualquier cosa dentro de 0.5–2 con un valor estándar de 1. Elegiremos una escala aleatoria en este intervalo Gety la pasaremos al enemigo a través de un nuevo método 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; } 

El método Enemy.Initializesimplemente establece la escala de su modelo que es la misma en todas las dimensiones.

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

inspector

escena

El rango de escalas es de 0.5 a 1.5.

Desplazamiento de ruta


Para destruir aún más la uniformidad del flujo de enemigos, podemos cambiar su posición relativa dentro de las fichas. Se mueven hacia adelante, por lo que el cambio en esta dirección solo cambia el momento de su movimiento, lo que no es muy notable. Por lo tanto, los desplazaremos hacia un lado, lejos del camino ideal que pasa por los centros de las baldosas. Agregue un EnemyFactorydesplazamiento de ruta al intervalo y pase el desplazamiento aleatorio al método Initialize. El desplazamiento puede ser negativo o positivo, pero nunca más de ½, ya que esto movería al enemigo a una casilla vecina. Además, no queremos que los enemigos vayan más allá de las fichas que siguen, por lo que, de hecho, el intervalo será menor, por ejemplo, 0.4, pero los límites reales dependen del tamaño del enemigo.

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

Dado que el desplazamiento del camino afecta el camino recorrido, Enemyes necesario seguirlo.

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

Cuando se mueve exactamente en línea recta (durante el movimiento de introducción, final o normal hacia adelante), simplemente aplicamos el desplazamiento directamente al modelo. Lo mismo sucede cuando regresas. Con un giro a la derecha o izquierda, ya desplazamos el modelo, que se vuelve relativo al desplazamiento de la ruta.

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

Dado que el desplazamiento de la ruta durante la rotación cambia el radio, necesitamos cambiar el proceso de cálculo del multiplicador de progreso. El desplazamiento de la ruta debe restarse de ½ para obtener el radio de rotación a la derecha, y agregarse en el caso de girar a la izquierda.

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

También obtenemos el radio de giro al girar 180 °. En este caso, cubrimos la mitad del círculo con un radio igual al desplazamiento de la ruta, por lo que la distancia es π veces el desplazamiento. Sin embargo, esto no funciona cuando el desplazamiento es cero, y en pequeños desplazamientos, los giros son demasiado rápidos. Para evitar giros instantáneos, podemos forzar el radio mínimo para calcular la velocidad, digamos 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


El desplazamiento de la ruta está en el rango −0.25–0.25.

Tenga en cuenta que ahora los enemigos nunca cambian su desplazamiento relativo del camino, incluso al girar. Por lo tanto, la longitud total del camino para cada enemigo tiene la suya.

Para evitar que los enemigos lleguen a las casillas vecinas, también se debe tener en cuenta su escala máxima posible. Simplemente limité el tamaño a un valor máximo de 1, por lo que el desplazamiento máximo permitido para el cubo es 0.25. Si el tamaño máximo fuera 1.5, entonces el desplazamiento máximo debería reducirse a 0.125.

Velocidad


Lo último que aleatorizamos es la velocidad de los enemigos. Agregamos un intervalo más para ello EnemyFactoryy transferiremos valor a la copia creada del enemigo. Hagámoslo el segundo argumento del método Initialize. Los enemigos no deben ser demasiado lentos o rápidos para que el juego no se vuelva trivialmente simple o imposible. Limitemos el intervalo a 0.2–5. La velocidad se expresa en unidades por segundo, que corresponde a las fichas por segundo solo cuando se avanza.

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

Ahora Enemytengo que seguir y acelerar.

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

Cuando no establecimos la velocidad explícitamente, simplemente usamos siempre el valor 1. Ahora solo necesitamos crear la dependencia del multiplicador de progreso en la velocidad.

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



Velocidad en el rango 0.75–1.25.

Entonces, tenemos una hermosa corriente de enemigos que se mueven hasta el punto final. En el próximo tutorial aprenderemos cómo lidiar con ellos. ¿Quieres saber cuándo se lanzará? ¡Sigue mi página en Patreon ! artículo PDF del

repositorio

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


All Articles