viernes, 28 de junio de 2013

Crea tu juego indie - Lección 05 - Movimiento del jugador

Entramos ya en el movimiento del personaje. En este ejemplo práctico, una implementación muy genérica de diferentes esquemas de control del personaje.

Es más que recomendable consultar la sección "¿Deseas saber más?", ya que hay bastante complejidad en algunos puntos. Como siempre, el código fuente estará disponible y libre para ser utilizado en vuestros juegos sin problemas. Pero si queréis hacer cambios o recortar y optimizar esta solución a vuestras necesidades, os ayudará saber bien cómo funciona por debajo.

(Muy recomendado verlo a pantalla completa en HD)

¿Deseas saber más?

El patrón de diseño Estrategia

Los patrones de diseño son soluciones bien conocidas y repetibles para problemas de diseño típicos. En este caso, se recurre a este patrón cuando se quiere desvincular el funcionamiento de una clase con la forma de llevar a cabo alguna (o algunas) operaciones.

En este patrón, se define una clase base que contiene todas las variables y métodos que se van a necesitar para utilizar esa estrategia, y luego se dan implementaciones particulares para esa estrategia. Cuando se va a usar la estrategia, se trata como si fuera el tipo genérico (la clase base), pero la implementación que se lanza por debajo es la del subtipo concreto. Algunos ejemplos:
  • Comprimir archivos:
    • Clase base: Compresor (incluye las funciones Comprimir y Descomprimir)
    • Subclases: CompresorZip, CompresorRar
  • Transferir archivos por red:
    • Clase base: ProtocoloDeTransferencia (incluye las funciones Subir y Descargar)
    • Subclases: TransferenciaHttp, Transferencia Https, TransferenciaFtp
Por ejemplo, utilizando el primer ejemplo para comprimir un archivo:
var comp : Compresor;
comp = new CompresorZip ();
// La instancia almacenada en comp es de tipo CompresorZip
// Ahora estaríamos llamando a CompresorZip.Comprimir()
comp.Comprimir (archivo); // Comprime archivo en un Zip

comp = new CompresorRar ();
// La instancia almacenada en comp es de tipo CompresorRar
// Ahora estaríamos llamando a CompresorRar.Comprimir()
comp.Comprimir (archivo); // Comprime archivo en un Rar
Como se puede ver el tratamiento que se hace de comp es siempre como si fuera de tipo Compresor, la clase base donde se definen las operaciones de la estrategia, pero como los tipos almacenados son subclases (estrategias concretas), se ejecuta la implementación específica de cada una. Como esas funciones vienen heredadas de la clase base, se puede asegurar (sin miedo a equivocarnos) que esas funciones van a existir en todos los subtipos. Esta es en mi opinión la mayor potencia de la herencia de tipos en la orientación a objetos.

Como podéis imaginar, es especialmente potente si además se pueden ir acoplando diferentes estrategias de forma externa. De hecho, así es como funcionan los plugins y los codecs de audio/vídeo: la aplicación que los va a usar expone una serie de funciones, y cada plugin/codec da su implementación concreta, a la que se accede según el plugin seleccionado en ese momento.

En nuestro caso, utilizamos este patrón en dos ocasiones diferentes: transformar la entrada a un espacio relativo (al avatar del jugador y a la cámara, concretamente), y aplicar esa entrada para dar movimiento al jugador. Con sólamente seleccionar una de cada, tenemos acceso a una de las cuatro combinaciones posibles. Añadiendo otra estrategia como, por ejemplo, un nuevo tipo de movimiento (scroll lateral para juegos de plataformas), serían seis combinaciones... Por eso, a pesar de ser algo más difícil de seguir, he preferido hacerlo así: están cubiertos casi todos los casos en los que el personaje se va a controlar usando un mando o el teclado, seleccionables desde dos desplegables en PlayerComponent ;)

Colecciones de datos

Las colecciones de datos son tipos especiales que almacenan varios datos de un mismo tipo.

La forma más básica de colecciones que incluyen casi todos los lenguajes de programación son los arrays. Un array es una variable que contiene no un dato de un tipo concreto, sino un lote estático de datos de ese tipo concreto. Es estático, porque una vez reservado el espacio para ese lote, éste no puede ni crecer ni disminuir. El símil que más cómodo me ha resultado siempre es ver un array como si fuera una cajonera. Se define el tipo (la forma del cajón) y al crear el array, se dice el número de elementos (número de cajones) o se le pasa una lista de elementos que tienen que caber, y se hace del tamaño apropiado para que entren todos justos. Ésto se ve muy bien con un ejemplo:
var arrayEnteros : int[];
// Se sabe que será una cajonera para 
// números enteros, pero no su tamaño.
arrayEnteros = new int[10];
// arrayEnteros es ahora una nueva cajonera, 
// vacía, y con capacidad para 10 números enteros.
arrayEnteros = new int[] {2, 3, 5, 7};
// arrayEnteros es un array de tamaño 4, y cada 
// elemento contiene uno de los cuatro 
// números primos menores de 10.
Para indicar que es un array en vez de una variable convencional, se añaden los dos corchetes [] justo después del tipo de elementos que va a contener (línea 1). Para crear el array se hace con el operador new, seguido del tipo y, o bien se especifica una capacidad entre los corchetes (línea 4), o bien se pasa una lista de elementos entre llaves y separados por comas (línea 7).

Pero, ¿cómo saber cuál de todos los valores dentro del array hay que cambiar? ¿O cuál queremos obtener? Se utiliza un operador conocido como operador de indexación. Justo después de la referencia que apunta al array, se pasa entre corchetes la posición del elemento al que queremos acceder:
var arrayEnteros : int[] = new int[3]; // {-,-,-}
arrayEnteros[0] = 23;  // {23,-,-}
arrayEnteros[1] = 57;  // {23,57,-}
arrayEnteros[2] = 911; // {23,57,911}

Debug.Log (arrayEnteros[1]); // Imprime "57"
Pero existen otro tipo de colecciones, de tamaño dinámico, que no necesariamente vienen de serie en cada lenguaje de programación. Son clases que "imitan" muchas de las funcionalidades de los arrays, pero ofrecen más versatilidad (aumentar o reducir su tamaño "al vuelo" añadiendo y quitando elementos, reordenarlos, hacer búsquedas de algún elemento...). En .NET las implementaciones básicas se encuentran en el espacio de nombres System.Collections, así que al principio del script hay que añadir import System.Collections; para poder acceder a estos tipos. Unity no es compatible con todos ellos, pero los más básicos están soportados sin problemas: List y Dictionary.

List es para listas de elementos. Se comporta esencialmente como un array, aunque puede variar su tamaño o reordenar los contenidos, entre otras cosas.
import System.Collections;
//...
var listaEnteros : List = new List (); // {}

listaEnteros.Add (23); // {23}
listaEnteros.Add (57); // {23,57}
Como son clases, la forma de crear las referencias a estas instancias se hace con el operador new, pero la sintaxis es diferente a la usada en los arrays.

Dictionary es para listas de pares asociados clave-valor. Las claves son únicas dentro de un mismo diccionario, pero no tienen por qué ser de tipo entero, ni guardar un orden.
import System.Collections;
//...
var diccionarioNumeros : Dictionary = new Dictionary (); // {}

diccionarioNumeros.Add (1, "Uno"); // {(1,"Uno")}
diccionarioNumeros.Add (4, "Cuatro"); // {(1,"Uno"),(4,"Cuatro")}
En un diccionario la versatilidad viene principalmente en que se puede acceder a los valores a través de la clave que llevan asociada, en vez de su posición dentro del diccionario.

Sin embargo, si os habéis fijado bien, en ningún momento hemos indicado ni el tipo que van a llevar los contenidos de la lista, ni de las claves y los valores en el diccionario. Eso es porque todos ellos se guardan como referencias al tipo genérico Object.
import System.Collections;
//...
var diccionarioNumeros : Dictionary = new Dictionary (); // {}

diccionarioNumeros.Add (1, "Uno"); // {(1,"Uno")}
// Si quisiéramos mostrar el valor de 1 en mayúsculas...
Debug.Log ((diccionarioNumeros[1] as String).ToUpper ());
Por estar utilizando estas versiones de las colecciones donde no es necesario especificar el tipo, cada vez que queramos utilizar alguno de sus contenidos, habrá que indicar explícitamente el tipo del contenido que hemos extraído (diccionarioNumeros[1] as String). Sin embargo y por suerte, también hay versiones de estas colecciones que hacen uso de la genericidad.

Genericidad

Hemos visto una de las desventajas de las colecciones básicas, ya que tener que indicar explícitamente el tipo que hemos extraído es tedioso y difícil de mantener. Pero además utilizar esas versiones es peligroso porque en ningún momento durante la compilación o la ejecución se comprueba que los tipos que se están introduciendo son válidos, y puede darnos errores muy difíciles de rastrear si creemos que el dato que estamos extrayendo es de un tipo pero en realidad es de otro. Sin ir más lejos, se pueden hacer este tipo de aberraciones:
import System.Collections;
//...
var diccionarioNumeros : Dictionary = new Dictionary (); // {}

diccionarioNumeros.Add (1, "Uno"); // {(1,"Uno")}
diccionarioNumeros.Add (4, 4); // {(1,"Uno"),(4,4)}
// Si quisiéramos mostrar el valor de 4 en mayúsculas...
Debug.Log ((diccionarioNumeros[4] as String).ToUpper ()); // ¡¡ERROR!!
// El valor asociado a 4 es 4, no una cadena,
// y ese error salta cuando se ejecuta esa línea, no cuando se compila.
Puede parecer poca cosa, o hasta versátil, pero se agradece mucho cuando el compilador te advierte de antemano cuando intentas meter un cuadrado en una colección donde sólo entran círculos.

La genericidad es una característica del lenguaje de programación que permite "parametrizar" las clases (no los valores de las instancias, sino la estructura de la clase en sí). Para ello, al definir una clase o una función, se especifica una serie de tipos parametrizables. Cuando queramos crear una referencia o nuevas instancias de estas clases, tenemos que indicar explícitamente los tipos que se han dejado parametrizables.

Aprovechando que .NET ofrece versiones parametrizables de List y Dictionary (en System.Collections.Generic), vamos a usarlas como ejemplo para ver cómo se trabaja con ellas, siguiendo el ejemplo anterior:
import System.Collections.Generic;
//...
var diccionarioNumeros : Dictionary.<int,String> = new Dictionary.<int,String> (); // {}

diccionarioNumeros.Add (1, "Uno"); // {(1,"Uno")}
diccionarioNumeros.Add (4, 4); // ¡¡ERROR!! (en tiempo de compilación)
// El compilador dirá que el valor tiene que ser un String, y que 4 es un int.
diccionarioNumeros.Add (4, "Cuatro"); // {(1,"Uno"),(4,"Cuatro")}
// Si quisiéramos mostrar el valor de 4 en mayúsculas...
Debug.Log (diccionarioNumeros[4].ToUpper ()); // Imprime "CUATRO".
Ahora con indicar los parámetros entre ángulos siguiendo ese formato tanto para declarar la variable como para instanciar un nuevo diccionario, tenemos una versión mucho más segura y cómoda de manejar de estas colecciones ;)

Proyecciones vectoriales

En este diagrama se ve cómo la proyección (v3) del vector v2 sobre v1 se calcula siguiendo la misma dirección que v1, pero el extremo de v3 es el punto más cercano desde el extremo de v2 a la recta directora de v1. Por expresarlo de otra forma más visual, si estuviésemos mirando perpendicularmente a v1, el vector v2 "proyectaría" sobre v1 una sombra: v3. De ahí el nombre proyección vectorial. En Unity se calcula fácilmente con la función Vector3.Project
v3 = Vector3.Project (v2, v1);

Una forma de definir un plano en matemáticas es a través de un punto contenido en el plano y un vector conocido como normal del plano. El plano es el conjunto infinito de rectas que pasan por ese punto y son perpendiculares al vector normal (expresado de otra forma, el vector normal es un vector perpendicular al plano). Luego si tenemos un plano (ignoraremos el punto y sólo nos centraremos en la orientación del plano), su vector normal n, y un vector cualquiera v:

 vn es la proyección de v sobre n. Se puede ver que v está formado por dos componentes: una sobre el vector normal n y la otra sobre el plano definido por esa normal. Si a v le restamos su proyección sobre la normal vn, obtenemos la proyección de v sobre el plano. En el caso del vídeo, esa normal es por defecto la dirección vertical Y, luego obtendremos la proyección sobre el plano XZ. Este es el vector vXZ

El cálculo de la transformación de entradas al sistema de referencia

Vamos a considerar la siguiente escena:
Tenemos una cámara y un personaje controlado por el jugador, cada uno con su orientación. Cuando vayamos a utilizar al jugador como sistema de referencia de las entradas, los vectores que determinarán la dirección hacia la que, teóricamente, querremos movernos al pulsar el eje vertical (azul) y el eje horizontal (rojo) serán éstos:
Si, por el contrario, empleásemos la otra estrategia, utilizar la cámara como sistema de referencia, primero proyectaríamos el vector morado sobre el suelo (para evitar "penetrar en el suelo" o "levitar") al pulsar hacia arriba o hacia abajo, y luego utilizaríamos esa proyección para el eje vertical; seguiríamos el mismo proceso para el eje horizontal. Éstos serían nuestros nuevos vectores de dirección asociados a cada eje del mando.
Estos vectores nos dan las direcciones de cada eje, y su longitud vendrá determinada por el valor de ese eje (como hemos dicho, +1.0 pulsando en un sentido, -1.0 pulsando en sentido contrario, y 0.0 en reposo).

Una vez tenemos esos vectores con las longitudes correctas, los combinamos en CalculateDirections para determinar la dirección que queremos que siga el personaje. La longitud/magnitud de este vector debería estar entre 0 y 1 (donde 0 es estar quieto, y 1 es moverse al máximo de velocidad). Sin embargo, si los sumamos para combinarlos, la longitud resultante de la suma puede ser mayor que 1 (vector v1), luego hay que recortar su longitud a 1, utilizando Vector3.ClampMagnitude (vector v2).
Multiplicando ese vector por la velocidad máxima correspondiente, obtenemos el vector de movimiento del personaje, que utilizaremos, por ejemplo, en playerCharacterController.SimpleMove. En la implementación de la estrategia de movimiento con la dirección fija hay un ejemplo de cómo utilizar los valores que hemos mantenido en inputData para determinar si se mueve hacia adelante o hacia atrás, si se mueve un poco hacia un lado... La utilidad de esto es, aparte de poder hacer variar su velocidad máxima (como hemos visto aquí), ¡para mezclar diferentes animaciones (correr hacia un lado, hacia atrás...), y obtener animaciones de movimiento más fluidas y naturales!

Interpolación esférica slerp

En la anterior entrada del blog veíamos la función lerp, que calculaba una interpolación lineal entre dos valores. También es posible utilizarla con vectores para obtener un punto intermedio. El problema es que, si bien es bastante conveniente para interpolar posiciones en una trayectoria lineal de un punto A a un punto B, cuando esa posición viene determinada por una rotación, la solución más cómoda es recurrir a lo que se conoce como interpolaciones esféricas. En el vídeo utilizábamos una función llamada slerp para suavizar los giros del avatar.
Es evidente que si a y b están muy juntos, apenas se nota diferencia entre utilizar lerp y slerp. Sin embargo, cuanto más se separen a y b, más se deformará el vector intermedio si utilizamos lerp. La propiedad de slerp frente a lerp es que en la interpolación esférica, la longitud del vector interpolado será la misma (bueno, técnicamente, interpolada entre las longitudes de a y de b, en un elipsoide más que en una esferea, pero nunca inferior a las dos, como puede ser el caso de lerp).

Descargas

Para descargar lo que llevamos de proyecto hasta ahora:
Descargar ARPG.rar (para descargar desde GDrive, ir a "Archivo -> Guardar como...")

No hay comentarios:

Publicar un comentario