Uno de los elementos clave de cualquier juego de plataformas 2D es el personaje principal. La forma en que se mueve y se controla da forma significativa a la atmósfera del juego, ya sea un juego acogedor de la vieja escuela o un slasher dinámico. Por lo tanto, la creación de un controlador de personaje es una etapa inicial importante en el desarrollo de un juego de plataformas.
En este artículo, examinaremos a fondo el proceso de creación de un personaje desde cero, enseñándole a moverse por el nivel respetando las leyes de la física. Incluso si ya tiene experiencia en la creación de controladores de personajes, le interesará conocer las innovaciones de Unity 2023. Para mi sorpresa, se ha agregado un método Slide
tan esperado para el componente Rigidbody2D
, que simplifica enormemente la escritura de un controlador de personajes al permitiendo el uso de Rigidbody2D
en modo cinemático de manera más efectiva. Anteriormente, toda esta funcionalidad debía implementarse manualmente.
Si no solo quieres leer el artículo sino también probarlo en la práctica, te recomiendo descargar una plantilla de nivel del repositorio de GitHub Treasure Hunters , donde ya se incluyen los recursos necesarios y un nivel listo para probar a tu personaje.
Para nuestro juego de plataformas, hemos establecido la regla de que el juego solo tendrá superficies verticales y horizontales, y la fuerza de gravedad se dirigirá estrictamente hacia abajo. Esto simplifica enormemente la creación de un juego de plataformas en la etapa inicial, especialmente si no quieres profundizar en las matemáticas vectoriales.
En el futuro, es posible que me desvíe de estas reglas en mi proyecto Treasure Hunters, donde exploro la creación de mecánicas para un juego de plataformas 2D en Unity. Pero ese será el tema de otro artículo.
En base a nuestra decisión de que el movimiento se realizará a lo largo de superficies horizontales, la base de nuestro personaje será rectangular. El uso de superficies inclinadas requeriría desarrollar un colisionador en forma de cápsula y mecanismos adicionales como el deslizamiento.
Primero, crea un objeto vacío en la escena y llámalo Capitán; este será nuestro personaje principal. Agregue los componentes Rigidbody2D
y BoxCollider2D
al objeto. Establezca el tipo Rigidbody2D
en Cinemático para que podamos controlar el movimiento del personaje mientras seguimos utilizando las capacidades físicas integradas de Unity. Además, bloquea la rotación del personaje a lo largo del eje Z activando la opción Congelar rotación Z.
Su configuración debería verse como la ilustración a continuación.
Ahora agreguemos una apariencia a nuestro capitán. Busque la textura en Recursos/Texturas/Cazadores de tesoros/Capitán Nariz de payaso/Sprites/Capitán Nariz de payaso/Capitán Nariz de payaso con espada/09-Idle Sword/Idle Sword 01.png y establezca su valor de Píxel por unidad en 32. A menudo lo haremos Utilice este valor en este curso porque nuestras texturas se crean con esta resolución. Establezca el Modo Sprite en Único y, para mayor comodidad, establezca el Pivote en Abajo. No olvide aplicar los cambios haciendo clic en el botón Aplicar. Asegúrese de que todas las configuraciones se realicen correctamente.
En este artículo, no tocaremos la animación de personajes, así que por ahora solo usaremos un objeto. En el objeto Capitán, cree un objeto anidado llamado Apariencia y agréguele un componente Sprite Renderer, especificando el sprite configurado previamente.
Cuando acerqué la imagen del capitán, noté que estaba bastante borrosa debido a una configuración incorrecta de los sprites. Para solucionar este problema, seleccione la textura Idle Sword 01 en la ventana Proyecto y establezca el Modo de filtro en Punto (sin filtro). Ahora la imagen se ve mucho mejor.
Por el momento, la posición del colisionador y la imagen de nuestro capitán no coinciden, y el colisionador resulta demasiado grande para nuestras necesidades.
Arreglemos esto y también posicionemos correctamente el pivote del personaje en la parte inferior. Aplicaremos esta regla a todos los objetos del juego para facilitar su colocación en el mismo nivel sin importar su tamaño.
Para facilitar la gestión de este proceso, utilice la herramienta Alternar posición del mango con conjunto de pivote, como se muestra a continuación.
El siguiente paso es ajustar el colisionador de nuestro héroe para que su punto de pivote esté exactamente en la parte inferior central. El tamaño del colisionador debe coincidir exactamente con las dimensiones del personaje. Ajuste los parámetros de Desplazamiento y Tamaño del colisionador, así como la posición del objeto de Apariencia anidado para lograr la precisión necesaria.
Preste especial atención al parámetro Offset.X del colisionador: su valor debe ser estrictamente 0. Esto asegurará la ubicación simétrica del colisionador con respecto al centro del objeto, lo cual es extremadamente importante para las rotaciones posteriores de caracteres hacia la izquierda y hacia la derecha, donde el valor de Transform.Scale.X se cambia a -1 y 1. El colisionador debe permanecer en su lugar y la rotación visual debe verse natural.
En primer lugar, es esencial enseñar a los personajes (ya sea el héroe principal, los PNJ o los enemigos) a interactuar con el mundo físico. Por ejemplo, los personajes deberían poder caminar sobre una superficie plana, saltar de ella y estar sujetos a la fuerza de la gravedad, empujándolos hacia el suelo.
Unity tiene un motor de física incorporado que controla el movimiento de los cuerpos, maneja colisiones y agrega el efecto de fuerzas externas sobre los objetos. Todo esto se implementa utilizando el componente Rigidbody2D
. Sin embargo, para los personajes, es útil tener una herramienta más flexible que interactúe con el mundo físico y al mismo tiempo brinde a los desarrolladores más control sobre el comportamiento del objeto.
Como mencioné anteriormente, en versiones anteriores de Unity, los desarrolladores tenían que implementar toda esta lógica ellos mismos. Sin embargo, en Unity 2023, se agregó un nuevo método Slide
al componente Rigidbody2D
, que permite un control flexible del objeto físico y al mismo tiempo proporciona toda la información necesaria sobre el movimiento realizado.
Comencemos creando una clase CharacterBody
, que contendrá la lógica básica para mover personajes en el mundo físico. Esta clase usará Rigidbody2D
en modo Kinematic
, que ya hemos agregado a nuestro personaje. Entonces, lo primero es agregar una referencia a este componente.
public class CharacterBody : MonoBehaviour { [SerializeField] private Rigidbody2D _rigidbody; }
En ocasiones, para la dinámica del movimiento de los personajes, es necesario que la gravedad actúe con más fuerza de lo habitual. Para lograrlo agregaremos un factor de influencia de la gravedad con un valor inicial de 1, y este factor no puede ser menor que 0.
[Min(0)] [field: SerializeField] public float GravityFactor { get; private set; } = 1f;
También necesitamos definir qué objetos se considerarán superficies intransitables. Para ello crearemos un campo que nos permitirá especificar las capas necesarias.
[SerializeField] private LayerMask _solidLayers;
Limitaremos la velocidad del personaje para evitar que desarrolle velocidades excesivamente altas, por ejemplo, debido a alguna influencia externa, incluida la gravedad. Establecemos el valor inicial en 30 y limitamos la capacidad de establecer un valor inferior a 0 en el inspector.
[Min(0)] [SerializeField] private float _maxSpeed = 30;
Al movernos por una superficie, queremos que el personaje siempre se aferre a ella si la distancia entre ellos es lo suficientemente pequeña.
[Min(0)] [SerializeField] private float _surfaceAnchor = 0.01f;
Aunque decidimos que las superficies de nuestro juego solo serían horizontales o verticales, por si acaso especificaremos el ángulo de inclinación máximo de la superficie sobre el que el personaje puede pararse de manera estable, con un valor inicial de 45º.
[Range(0, 90)] [SerializeField] private float _maxSlop = 45f;
A través del inspector, también quiero ver la velocidad actual del personaje y su estado, así que agregaré dos campos con el atributo SerializeField
.
[SerializeField] private Vector2 _velocity; [field: SerializeField] public CharacterState State { get; private set; }
Sí, aquí presenté una entidad nueva, aún no definida, CharacterState
. Discutiremos esto más a fondo.
Para simplificar el desarrollo de nuestro juego de plataformas, definamos solo dos estados de los personajes principales.
El primero es Grounded , un estado en el que el personaje se encuentra de forma segura en la superficie. En este estado, el personaje puede moverse libremente por la superficie y saltar de ella.
El segundo es Airborne , un estado de caída libre, en el que el personaje se encuentra en el aire. El comportamiento del personaje en este estado puede variar según las características específicas del juego de plataformas. En un caso general cercano a la realidad, el personaje se mueve bajo la influencia del impulso inicial y no puede afectar su comportamiento. Sin embargo, en los juegos de plataformas, la física a menudo se simplifica en favor de la comodidad y la dinámica del juego: por ejemplo, en muchos juegos, incluso en caída libre, podemos controlar el movimiento horizontal del personaje. En nuestro caso esto también es posible, además de la popular mecánica del doble salto, que permite un salto adicional en el aire.
Representemos los estados de nuestro personaje en el código:
/// <summary> /// Describes the state of <see cref="CharacterBody"/>. /// </summary> public enum CharacterState { /// <summary> /// The character stays steady on the ground and can move freely along it. /// </summary> Grounded, /// <summary> /// The character is in a state of free fall. /// </summary> Airborne }
Vale la pena señalar que puede haber muchos más estados. Por ejemplo, si el juego incluye superficies inclinadas, el personaje puede estar en un estado de deslizamiento en el que no puede moverse libremente hacia la derecha o hacia la izquierda, pero puede deslizarse cuesta abajo. En tal estado, el personaje también puede saltar, empujándose desde la pendiente, pero sólo en la dirección de la pendiente. Otro posible caso es deslizarse a lo largo de una pared vertical, donde la influencia de la gravedad se debilita y el personaje puede empujarse horizontalmente.
Ya hemos definido un campo privado _velocity
, pero necesitamos poder obtener y establecer este valor desde afuera mientras limitamos la velocidad máxima del personaje. Esto requiere comparar el vector de velocidad dado con la velocidad máxima permitida.
Esto se puede hacer calculando la longitud del vector velocidad o, en términos matemáticos, su magnitud. La estructura Vector2
ya contiene una propiedad magnitude
que nos permite hacer esto. Por lo tanto, si la magnitud del vector que pasa excede la velocidad máxima permitida, debemos mantener la dirección del vector pero limitar su magnitud. Para esto, multiplicamos _maxSpeed
por el vector de velocidad normalizado (un vector normalizado es un vector con la misma dirección pero con una magnitud de 1).
Así es como se ve en el código:
public Vector2 Velocity { get => _velocity; set => _velocity = value.magnitude > _maxSpeed ? value.normalized * _maxSpeed : value; }
Ahora echemos un vistazo de cerca a cómo se calcula la magnitud de un vector. Está definido por la fórmula:
Calcular la raíz cuadrada es una operación que requiere muchos recursos. Aunque en la mayoría de casos la velocidad no superará el máximo, igual debemos realizar esta comparación al menos una vez por ciclo. Sin embargo, podemos simplificar significativamente esta operación si comparamos el cuadrado de la magnitud del vector con el cuadrado de la velocidad máxima.
Para ello introducimos un campo adicional para almacenar el cuadrado de la velocidad máxima, y lo calculamos una vez en el método Awake
:
private float _sqrMaxSpeed; private void Awake() { _sqrMaxSpeed = _maxSpeed * _maxSpeed; }
Ahora el ajuste de la velocidad se puede realizar de forma más óptima:
public Vector2 Velocity { get => _velocity; set => _velocity = value.sqrMagnitude > _sqrMaxSpeed ? value.normalized * _maxSpeed : value; }
Así, evitamos cálculos innecesarios y mejoramos el rendimiento del procesamiento de la velocidad de movimiento del personaje.
Como mencioné anteriormente, Unity ha agregado un nuevo método Slide()
, que simplificará enormemente el desarrollo de nuestro CharacterBody
. Sin embargo, antes de utilizar este método, es necesario definir las reglas según las cuales el objeto se moverá en el espacio. Este comportamiento lo establece la estructura Rigidbody2D.SlideMovement
.
Introduzcamos un nuevo campo _slideMovement
y establezcamos sus valores.
private Rigidbody2D.SlideMovement _slideMovement; private void Awake() { _sqrMaxSpeed = _maxSpeed * _maxSpeed; _slideMovement = CreateSlideMovement(); } private Rigidbody2D.SlideMovement CreateSlideMovement() { return new Rigidbody2D.SlideMovement { maxIterations = 3, surfaceSlideAngle = 90, gravitySlipAngle = 90, surfaceUp = Vector2.up, surfaceAnchor = Vector2.down * _surfaceAnchor, gravity = Vector2.zero, layerMask = _solidLayers, useLayerMask = true, }; }
Es importante explicar que maxIterations
determina cuántas veces un objeto puede cambiar de dirección como resultado de una colisión. Por ejemplo, si el personaje está en el aire junto a una pared y el jugador intenta moverla hacia la derecha mientras la gravedad actúa sobre ella. Por lo tanto, para cada llamada al método Slide()
, se establecerá un vector de velocidad dirigido hacia la derecha y hacia abajo. Al golpear la pared, el vector de movimiento se recalcula y el objeto continuará moviéndose hacia abajo.
En tal situación, si el valor maxIterations
se estableciera en 1, el objeto golpearía la pared, se detendría y efectivamente se quedaría atascado allí.
Los valores para maxIterations
y layerMask
se definieron previamente. Para obtener información más detallada sobre los otros campos, consulte la documentación oficial de la estructura .
Ahora todo está listo para que el Capitán se mueva. Haremos esto en FixedUpdate
, una devolución de llamada en Unity diseñada para manejar la física. En los últimos años, el equipo de Unity ha mejorado significativamente el manejo de la física 2D. Actualmente, el procesamiento se puede realizar en la devolución de llamada Update
o incluso llamando al método requerido por su cuenta.
Sin embargo, en este ejemplo, utilizaremos el método tradicional y probado FixedUpdate
. Antes de continuar, vale la pena mencionar algunas palabras sobre el valor de Time.fixedDeltaTime
.
Para garantizar la previsibilidad en la física del juego, la simulación se lleva a cabo en iteraciones en intervalos de tiempo fijos. Esto garantiza que los cambios en FPS o retrasos no afecten el comportamiento de los objetos.
Al comienzo de cada ciclo, tendremos en cuenta el efecto de la gravedad sobre el objeto. Dado que la gravedad viene dada por el vector de aceleración de caída libre, podemos calcular el cambio en la velocidad Δv
del objeto a lo largo del tiempo Δt
mediante la fórmula:
donde a
es la aceleración constante del objeto. En nuestro caso, es la aceleración debida a la gravedad, considerando el coeficiente que introdujimos: Physics2D.gravity * GravityFactor
. Por tanto, Δv
se puede calcular de la siguiente manera:
Time.fixedDeltaTime * GravityFactor * Physics2D.gravity
El resultado final, donde cambiamos la velocidad, se ve así:
Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity;
Ahora podemos realizar el movimiento de cuerpo rígido del personaje:
var slideResults = _rigidbody.Slide( _velocity, Time.fixedDeltaTime, _slideMovement);
La variable slideResults
es un valor de la estructura SlideResults
y almacena los resultados del movimiento. Los campos principales de este resultado para nosotros son slideHit
, el resultado de la colisión con la superficie durante el movimiento, y surfaceHit
, el resultado de un lanzamiento hacia abajo, que ayudará a determinar si el personaje está parado sobre una superficie estable.
Al chocar con superficies, es crucial limitar la velocidad del personaje dirigida hacia esa superficie. Un ejemplo simple es si el personaje está parado en el suelo, no debe continuar ganando velocidad bajo la influencia de la gravedad. Al final de cada ciclo, su velocidad debe ser cero. De manera similar, al moverse hacia arriba y golpear el techo, el personaje debería perder toda velocidad vertical y comenzar a moverse hacia abajo.
Los resultados de las colisiones, slideHit
y surfaceHit
, están representados por valores de la estructura RaycastHit2D
, que incluye la normal de la superficie de colisión.
La limitación de velocidad se puede calcular restando la proyección del vector de velocidad original sobre la normal de colisión del propio vector de velocidad. Esto se hace usando el producto escalar . Escribamos un método que realizará esta operación:
private static Vector2 ClipVector(Vector2 vector, Vector2 hitNormal) { return vector - Vector2.Dot(vector, hitNormal) * hitNormal; }
Ahora integremos este método en nuestro FixedUpdate
. Aquí, para surfaceHit
, solo limitaremos la velocidad si se dirige hacia abajo, ya que el lanzamiento que determina si el objeto está en la superficie siempre se realiza para verificar el contacto con el suelo.
private void FixedUpdate() { Velocity += Time.fixedDeltaTime * GravityFactor * Physics2D.gravity; var slideResults = _rigidbody.Slide( _velocity, Time.fixedDeltaTime, _slideMovement); if (slideResults.slideHit) { _velocity = ClipVector(_velocity, slideResults.slideHit.normal); } if (_velocity.y <= 0 && slideResults.surfaceHit) { var surfaceHit = slideResults.surfaceHit; _velocity = ClipVector(_velocity, surfaceHit.normal); } }
Esta implementación permite gestionar correctamente el movimiento del personaje, evitando aceleraciones no deseadas al chocar con varias superficies y manteniendo el movimiento de los personajes en el juego predecible y fluido.
Al final de cada ciclo, es necesario determinar si el personaje se encuentra sobre una superficie sólida (estado a tierra) o en caída libre (o, como lo definimos, caída controlada: estado en el aire).
Para considerar que el personaje está en el estado Conectado, principalmente, su velocidad vertical debe ser cero o negativa, que determinamos por el valor de _velocity.y
.
Otro criterio importante es la presencia de una superficie debajo de los pies del personaje, que identificamos a partir de los resultados del movimiento de Rigidbody, es decir, a través de la presencia de surfaceHit
.
El tercer factor es el ángulo de inclinación de la superficie, que analizamos en función de la normal de esta superficie, es decir, el valor de surfaceHit.normal
. Es necesario comparar este ángulo con _maxSlop
, el ángulo máximo posible de la superficie sobre la que el personaje puede permanecer estable.
Para una superficie completamente vertical, la normal será estrictamente horizontal, es decir, su valor vectorial será (1, 0) o (-1, 0). Para una superficie horizontal, el valor de la normal será (0, 1). Cuanto menor sea el ángulo de inclinación, mayor será el valor de y
. Para el ángulo alpha
, este valor se puede calcular como:
Dado que nuestro ángulo está dado en grados y la función $\cos$ requiere radianes, la fórmula se transforma en:
Para ello, introduzcamos un nuevo campo y calculemoslo en el método Awake
.
private float _minGroundVertical; private void Awake() { _minGroundVertical = Mathf.Cos(_maxSlop * Mathf.PI / 180f); //... }
Ahora actualicemos nuestro código en FixedUpdate
, verificando todas las condiciones anteriores.
if (_velocity.y <= 0 && slideResults.surfaceHit) { var surfaceHit = slideResults.surfaceHit; Velocity = ClipVector(_velocity, surfaceHit.normal); State = surfaceHit.normal.y >= _minGroundVertical ? CharacterState.Grounded : CharacterState.Airborne; } else { State = CharacterState.Airborne; }
Esta lógica nos permitirá determinar con precisión cuándo el personaje está en el suelo y responder correctamente a los cambios en su estado.
Ahora que nuestro componente CharacterBody está listo, el paso final es agregarlo a nuestro Capitán. En la escena, seleccione el objeto Capitán y agréguele el componente CharacterBody
.
No olvide configurar Rigidbody como se muestra en la ilustración anterior. Establezca el Factor de gravedad en 3 y seleccione la opción Predeterminada para Capa sólida.
Ahora puedes comenzar el juego y experimentar estableciendo diferentes valores de Velocidad para observar cómo se mueve nuestro personaje por la escena.
Por supuesto, todavía necesitamos agregar controles de personajes. Sin embargo, este artículo ya se ha vuelto bastante largo, por lo que detallaré el control del personaje usando el nuevo sistema de entrada en el próximo artículo: "Creación de un controlador de personaje 2D en Unity: Parte 2".
Puedes descargar el proyecto completo descrito en este artículo aquí: Treasure Hunters y comprobarlo todo en la práctica si encuentras alguna dificultad. Desarrollar un controlador de personaje es un aspecto clave en la creación de un juego de plataformas 2D, ya que determina el desarrollo posterior del juego. Afecta la facilidad con la que se agregarán nuevas características al comportamiento del héroe principal o de los enemigos. Por eso, es muy importante entender los conceptos básicos para poder desarrollar tu propio juego de forma independiente.