[ 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 { …
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 prefabricadaCreemos 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 cuboPor 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) {
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 Enemy
que 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;
SpawnOn
cada 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 From
y To
la misma. public void SpawnOn (GameTile tile) { Debug.Assert(tile.NextTileOnPath != null, "Nowhere to go!", this); tileFrom = tile; tileTo = tile.NextTileOnPath;
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 From
y To
, porque los usamos aquí para verificar si el enemigo ha terminado el camino. public bool GameUpdate () { progress += Time.deltaTime; while (progress >= 1f) { …
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 To
a 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 To
coincide 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 To
debe 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.Euler
manejarlo. void PrepareTurnRight () { directionAngleTo = directionAngleFrom + 90f; } void PrepareTurnLeft () { directionAngleTo = directionAngleFrom - 90f; } void PrepareTurnAround () { directionAngleTo = directionAngleFrom + 180f; }
Al final, PrepareNextState
podemos usar switch
para 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 GameUpdate
necesitamos 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 From
y To
en 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 Enemy
enlace 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 Direction
un 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.GrowPathTo
mitad 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); }
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) { …
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 ¼π. progress
igual 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 GameUpdate
no destruir al enemigo demasiado pronto, eliminaremos el cambio de fichas. Lo hará ahora PrepareNextState
. Por lo tanto, buscar null
devoluciones true
solo después del final del estado final. public bool GameUpdate () { progress += Time.deltaTime * progressFactor; while (progress >= 1f) {
En PrepareNextState
comenzaremos con el cambio de fichas. Luego, después de establecer la posición From
, pero antes de establecer la posición, To
comprobaremos si el mosaico es igual al To
valor 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 FloatRange
que 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 FloatRangeSliderDrawer
en 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 EnemyFactory
la 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 Get
y 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.Initialize
simplemente 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); }
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 EnemyFactory
desplazamiento 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, Enemy
es 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)); }
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 EnemyFactory
y 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 Enemy
tengo 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 delrepositorio