Creando un juego de ritmo en Unity

imagen

Introduccion


Entonces, quieres o intentaste crear un juego de ritmo, pero los elementos del juego y la música rápidamente no están sincronizados, y ahora no sabes qué hacer. Este artículo te ayudará con esto. Jugué juegos de ritmo desde la escuela secundaria y, a menudo, pasaba el rato en DDR en la sala de juegos. Hoy siempre estoy buscando nuevos juegos de este género, y proyectos como Crypt of the Necrodancer o Bit.Trip.Runner muestran que se puede hacer mucho más en este género. Trabajé un poco en prototipos de juegos de ritmo en Unity, y como resultado pasé un mes creando un juego de ritmo corto / rompecabezas Atomic Beats . En este artículo, hablaré sobre las técnicas de creación de código más útiles que aprendí al crear estos juegos. No pude encontrar información sobre ellos en ningún otro lado, o se presentó con menos detalle.

Primero, debo expresar mi profunda gratitud a Yu Chao por la publicación de Music Syncing in Rhythm Games [ traducción al Habré ]. Yu revisó los conceptos básicos de la sincronización de los tiempos de audio con el motor del juego en Unity y subió el código fuente de su juego Boots-Cut, que me ayudó mucho a crear mi proyecto. Puede estudiar su publicación si desea obtener una breve introducción a la sincronización de música de Unity, pero abordaré este tema con más detalle y de manera más extensa. Mi código utiliza activamente la información del artículo y el código Boots-Cut.

En el corazón de cualquier juego de ritmo están los tiempos. Las personas son extremadamente sensibles a cualquier distorsión en los tiempos de ritmo, por lo que es muy importante que todas las acciones, movimientos y entradas en el juego de ritmo estén directamente sincronizados con la música. Desafortunadamente, los métodos tradicionales de seguimiento de tiempo de Unity como Time.timeSinceLevelLoad y Time.time perderán rápidamente la sincronización con el sonido que se está reproduciendo. Por lo tanto, accederemos al sistema de audio directamente usando AudioSettings.dspTime , que usa la cantidad real de muestras de audio procesadas por el sistema de audio. Gracias a esto, siempre mantiene la sincronización con la música que se está reproduciendo (quizás este no sea el caso con archivos de audio muy largos, cuando entran en juego los efectos de muestreo, pero en el caso de canciones de una duración normal, el sistema debería funcionar perfectamente). Esta función será el núcleo de nuestro seguimiento del tiempo de composición, y en base a ella crearemos la clase principal.

Conductor de clase


La clase Conductor es la clase principal de gestión de composición sobre la base de la cual se construirá el resto del juego de ritmo. Con él, realizaremos un seguimiento de la posición de la composición y administraremos todas las demás acciones sincronizadas. Para rastrear la composición necesitamos algunas variables

//Song beats per minute //This is determined by the song you're trying to sync up to public float songBpm; //The number of seconds for each song beat public float secPerBeat; //Current song position, in seconds public float songPosition; //Current song position, in beats public float songPositionInBeats; //How many seconds have passed since the song started public float dspSongTime; //an AudioSource attached to this GameObject that will play the music. public AudioSource musicSource; 

Al comenzar la escena, necesitamos realizar cálculos para determinar las variables, y también registrar como referencia el tiempo de inicio de la composición.

 void Start() { //Load the AudioSource attached to the Conductor GameObject musicSource = GetComponent<AudioSource>(); //Calculate the number of seconds in each beat secPerBeat = 60f / songBpm; //Record the time when the music starts dspSongTime = (float)AudioSettings.dspTime; //Start the music musicSource.Play(); } 

Si crea un GameObject vacío con dicho script adjunto, y luego agrega la Fuente de audio con la composición y ejecuta el programa, verá que el script registrará la hora de inicio de la composición, pero no sucederá nada más. También necesitaremos ingresar manualmente los BPM de la música que agreguemos a la fuente de audio.


Gracias a todos estos valores, podemos rastrear la posición en la composición en tiempo real al actualizar el juego. Determinaremos el tiempo de la composición, primero en segundos, luego en fracciones. Las fracciones son una forma mucho más conveniente de rastrear una composición, ya que nos permiten agregar acciones y temporizaciones en el tiempo en paralelo con la composición, por ejemplo, en las fracciones 1, 3 y 5.5, sin la necesidad de calcular segundos entre fracciones. Agregue los siguientes cálculos a la función Update () de la clase Conductor:

 void Update() { //determine how many seconds since the song started songPosition = (float)(AudioSettings.dspTime - dspSongTime); //determine how many beats since the song started songPositionInBeats = songPosition / secPerBeat; } 

Entonces obtenemos la diferencia entre la hora actual de acuerdo con el sistema de audio y la hora de inicio de la composición, que da el número total de segundos que se reproduce la composición. Lo guardaremos en la variable songPosition.


Tenga en cuenta que la partitura en la música generalmente comienza con una unidad con fracciones 1-2-3-4 y así sucesivamente, y songPositionInBeats comienza en 0 y aumenta a partir de este valor, por lo que la tercera parte de la composición corresponderá a songPositionInBeats, que es 2.0, no 3.0.

En este punto, si desea crear un juego tradicional de estilo Dance Dance Revolution, debe crear notas de acuerdo con la fracción en la que necesita presionarlas, interpolar su posición con respecto a la línea de clic y luego grabar songPositionInBeats cuando se presiona la tecla, y Compare el valor con la proporción deseada de notas. Yu Chao analiza un ejemplo de tal esquema en su artículo . Para no repetirme, consideraré otras técnicas potencialmente útiles que se pueden construir sobre la clase de Conductor. Los usé al crear Atomic Beats .

Nos adaptamos a la cuota inicial


Si crea su propia música para un juego de ritmo, es fácil hacer que el primer ritmo coincida exactamente con el comienzo de la música, que, si se especifica correctamente, unirá de manera confiable la canción de la clase ConductorPositionInBeats a la composición.


Sin embargo, si usa música ya preparada, existe una alta probabilidad de que haya una pequeña pausa antes del comienzo de la composición. Si esto no se tiene en cuenta, la canción de la clase ConductorPositionInBeats pensará que el primer ritmo comenzó cuando comenzó a sonar la canción, y no el ritmo ahora. Todo lo que estará más vinculado a los valores de las acciones no está sincronizado con la música.


Para solucionar esto, puede agregar una variable que tenga en cuenta este desplazamiento. Agregue lo siguiente a la clase Conductor:

 //The offset to the first beat of the song in seconds public float firstBeatOffset; 

En Update (), la variable songPosition:

 songPosition = (float)(AudioSettings.dspTime - dspSongTime); 

reemplazado por:

 songPosition = (float)(AudioSettings.dspTime - dspSongTime - firstBeatOffset); 

Ahora songPosition calculará correctamente la posición en la canción, teniendo en cuenta el verdadero primer tiempo. Sin embargo, deberá ingresar manualmente el desplazamiento al primer tiempo, por lo que para cada archivo será único. Además, durante este cambio habrá una pequeña ventana en la que songPosition resultará negativa. Es posible que esto no afecte el juego, pero algunos códigos, dependiendo de los valores de songPosition o songPositionInBeats, pueden no ser capaces de procesar números negativos en este momento.



Repeticiones


Si trabaja con una composición que se reproduce de principio a fin, la clase de Director que se muestra arriba será suficiente para seguir la posición. Pero si tiene una pista corta que está en bucle y desea trabajar con este bucle, debe incorporar el soporte de repetidor en Conductor.

Si tiene un fragmento en bucle perfecto (por ejemplo, si el tempo de la composición es de 120 lpm y el fragmento en bucle tiene una longitud de 4 latidos, debería ser exactamente 8,0 segundos a 2,0 segundos por acción) cargado en la clase de fuente de audio del conductor, luego marque la casilla de bucle. Conductor funcionará de la misma manera que antes, y transferirá el tiempo total a songPosition después del primer inicio del clip. Para determinar la posición del bucle, necesitamos decirle al conductor cuántas acciones hay en un bucle y cuántos bucles ya se han reproducido. Agregue las siguientes variables a la clase Conductor:

 //the number of beats in each loop public float beatsPerLoop; //the total number of loops completed since the looping clip first started public int completedLoops = 0; //The current position of the song within the loop in beats. public float loopPositionInBeats; 

Ahora con cada actualización de SongPositionInBeats, también podemos actualizar la posición Update () del bucle.

 //calculate the loop position if (songPositionInBeats >= (completedLoops + 1) * beatsPerLoop) completedLoops++; loopPositionInBeats = songPositionInBeats - completedLoops * beatsPerLoop; 

Esto nos da un marcador que le dice a loopPositionInBeats cuántos recursos compartimos por el bucle, lo que es útil para muchos otros elementos sincronizados. Recuerde ingresar el número de acciones del bucle en GameObject Conductor.

También debemos considerar cuidadosamente el cálculo de las acciones. La música siempre comienza en 1, por lo que la medición de 4 partes toma la forma 1-2-3-4-, y en nuestra clase loopPositionInBeats comienza en 0.0 y pasa por encima de 4.0. Por lo tanto, el centro exacto del bucle, que al calcular las proporciones musicales será 3, en loopPositionInBeats tendrá un valor de 2.0. Puede modificar loopPositionInBeats para tener esto en cuenta, pero esto afectará a todos los demás cálculos, así que tenga cuidado al insertar notas.

También para las herramientas restantes será útil agregar dos aspectos más a la clase Conductor. En primer lugar, una versión analógica de LoopPositionInBeats llamada LoopPositionInAnalog, que mide la posición en el bucle en el rango de 0 a 1.0. El segundo es una instancia de la clase Conductor para llamadas convenientes desde otras clases. Agregue las siguientes variables a la clase Conductor:

 //The current relative position of the song within the loop measured between 0 and 1. public float loopPositionInAnalog; //Conductor instance public static Conductor instance; 

En la función Despertar (), agregue:

 void Awake() { instance = this; } 

y agregue a la función Update ():

 loopPositionInAnalog = loopPositionInBeats / beatsPerLoop; 

Turn Sync


Sería muy útil sincronizar el movimiento o la rotación con los lóbulos para que los elementos estén en los lugares correctos. En mi juego Atomic Beats, usé esto para rotar dinámicamente las notas alrededor de un eje central. Inicialmente, se colocaron alrededor de la circunferencia de acuerdo con su parte dentro del bucle, y luego se rotó toda el área de juego para que las notas coincidieran con la línea de depresión en su parte.

Para lograr esto, cree un nuevo script llamado SyncedRotation y adjúntelo al GameObject que desea rotar. Agregue a la función Update () del script SyncedRotation:

 void Update() { this.gameObject.transform.rotation = Quaternion.Euler(0, 0, Mathf.Lerp(0, 360, Conductor.instance.loopPositionInAnalog)); } 

Este código interpolará la rotación del GameObject al que está vinculado este juego en el rango de 0 a 360 grados, girándolo para que complete una revolución completa al final de cada ciclo. Esto es útil como ejemplo, pero para la animación en bucle o cuadro por cuadro, sería más útil sincronizar las animaciones en bucle para que encajen perfectamente con el tempo.

Sincronización de animación


Unity Animator es extremadamente poderoso, pero no siempre es preciso. Para una alineación confiable de animaciones y música, tuve que competir con la clase Animator y su tendencia a desincronizarse gradualmente con el ritmo. Además, era difícil ajustar las mismas animaciones a diferentes tempos, de modo que al cambiar entre composiciones, no tenía que redefinir los fotogramas clave de la animación al tempo actual. En cambio, podemos ir directamente al bucle de animación y establecer la posición en este bucle de acuerdo con el lugar en el que nos encontramos en el bucle de la clase Conductor.

Primero, cree una nueva clase llamada SyncedAnimation y agréguele las siguientes variables:

 //The animator controller attached to this GameObject public Animator animator; //Records the animation state or animation that the Animator is currently in public AnimatorStateInfo animatorStateInfo; //Used to address the current state within the Animator using the Play() function public int currentState; 

Adjúntelo a un GameObject nuevo o existente que desee animar. En este ejemplo, simplemente moveremos el objeto de un lado a otro de la pantalla, pero el mismo principio se puede aplicar a cualquier animación, ya sea antes de establecer la propiedad, o de la animación cuadro por cuadro. Agregue un elemento Animator a GameObject y cree un nuevo Controlador Animator llamado SyncedAnimController, así como un Clip de Animación llamado BackAndForth. Cargamos el controlador en la clase Animator adjunta al GameObject y agregamos Animación al árbol de animación como animación predeterminada.


Por ejemplo, configuré la animación para que primero mueva el objeto a la derecha en 6 unidades, luego a la izquierda en -6 y luego nuevamente a 0.


Ahora, para sincronizar la animación, agregue el siguiente código a la función Start () de la clase SyncedAnimation, que inicializa la información sobre Animator:

 void Start() { //Load the animator attached to this object animator = GetComponent<Animator>(); //Get the info about the current animator state animatorStateInfo = animator.GetCurrentAnimatorStateInfo(0); //Convert the current state name to an integer hash for identification currentState = animatorStateInfo.fullPathHash; } 

Luego agregue el siguiente código a Update () para configurar la animación:

 void Update() { //Start playing the current animation from wherever the current conductor loop is animator.Play(currentState, -1, (Conductor.instance.loopPositionInAnalog)); //Set the speed to 0 so it will only change frames when you next update it animator.speed = 0; } 

Entonces colocamos la animación en el cuadro exacto de la animación en relación con un bucle completo. Por ejemplo, si usa la animación anterior, cuando se encuentra en el medio del bucle, la posición de GameObject solo cruzará 0. Esto se puede aplicar a cualquier animación que cree que desee sincronizar con el tempo del Conductor.

También vale la pena señalar que para crear un bucle continuo de animaciones, debe configurar las tangentes de fotogramas clave individuales de la animación en la curva de animación. La configuración Lineal creará una línea recta que va de un cuadro clave al siguiente, y Constant mantendrá la animación en un valor hasta el siguiente cuadro clave, lo que dará un movimiento brusco y brusco.


Aunque este método es útil, afecta a todas las transiciones de la animación, ya que hace que animationState permanezca en el estado en que estaba cuando se ejecutó el script por primera vez. Este método es útil para los objetos que solo necesitan usar infinitamente una animación sincronizada, pero para crear objetos más complejos con diferentes animaciones sincronizadas, debe agregar código que procese estas transiciones y establezca la variable currentState de acuerdo con el estado de animación deseado.

Conclusión


Estos son solo algunos de los aspectos que me han sido útiles para crear Atomic Beats. Algunos de ellos fueron recolectados de otras fuentes o creados por necesidad, pero la mayoría de ellos no los pude encontrar en la forma final, ¡así que espero que esto sea útil! Quizás parte de mi sistema ya no sea útil en proyectos grandes debido a limitaciones de la CPU o del sistema de audio, pero será una buena base para jugar un atasco o un proyecto de pasatiempo.

imagen

Crear un juego de ritmo o elementos de juego sincronizados con música puede ser difícil. Para mantener todo a un ritmo constante, es posible que necesite un código complicado; un resultado que le permita jugar a un ritmo constante puede ser muy atractivo para el jugador. Se puede hacer mucho más en este género que los juegos en el estilo tradicional Dance Dance Revolution, y espero que este artículo te ayude a realizar tales proyectos. También recomiendo, si es posible, evaluar mi juego Atomic Beats . Lo hice en un mes en la primavera de este año, tiene 8 pistas cortas y es gratis.

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


All Articles