Création d'un jeu de rythme dans Unity

image

Présentation


Donc, vous voulez ou essayez de créer un jeu de rythme, mais les éléments du jeu et la musique sont rapidement désynchronisés, et maintenant vous ne savez pas quoi faire. Cet article vous y aidera. J'ai joué à des jeux de rythme au lycée et je traînais souvent sur DDR dans la salle d'arcade locale. Aujourd'hui, je suis toujours à la recherche de nouveaux jeux de ce genre, et des projets tels que Crypt of the Necrodancer ou Bit.Trip.Runner montrent que beaucoup plus peut être fait dans ce genre. J'ai un peu travaillé sur des prototypes de jeux de rythme dans Unity, et en conséquence j'ai passé un mois à créer un petit jeu de rythme / puzzle Atomic Beats . Dans cet article, je parlerai des techniques de construction de code les plus utiles que j'ai apprises en créant ces jeux. Je n'ai pu trouver d'informations à leur sujet nulle part ailleurs, ou elles ont été présentées avec moins de détails.

Tout d'abord, je dois exprimer ma profonde gratitude à Yu Chao pour le poste de Music Syncing in Rhythm Games [ traduction en Habré ]. Yu a passé en revue les bases de la synchronisation des timings audio avec le moteur de jeu dans Unity et a téléchargé le code source de son jeu Boots-Cut, ce qui m'a beaucoup aidé dans la création de mon projet. Vous pouvez étudier son article si vous souhaitez apprendre une brève introduction à la synchronisation musicale d'Unity, mais je couvrirai ce sujet plus en détail et de manière plus approfondie. Mon code utilise activement les informations de l'article et le code Boots-Cut.

Au cœur de tout jeu de rythme se trouvent les timings. Les gens sont extrêmement sensibles à toute distorsion dans les timings rythmiques, il est donc très important que toutes les actions, mouvements et entrées dans le jeu rythmique soient directement synchronisés avec la musique. Malheureusement, les méthodes traditionnelles de suivi du temps Unity comme Time.timeSinceLevelLoad et Time.time perdent rapidement la synchronisation avec le son en cours de lecture. Par conséquent, nous accéderons directement au système audio à l'aide d' AudioSettings.dspTime , qui utilise le nombre réel d'échantillons audio traités par le système audio. Grâce à cela, il maintient toujours la synchronisation avec la musique en cours de lecture (ce n'est peut-être pas le cas avec des fichiers audio très longs, lorsque des effets d'échantillonnage entrent en jeu, mais dans le cas de chansons d'une durée normale, le système devrait fonctionner parfaitement). Cette fonction sera au cœur de notre suivi du temps de composition, et sur cette base, nous créerons la classe principale.

Chef de classe


La classe de chef d'orchestre est la principale classe de gestion de composition sur la base de laquelle le reste du jeu de rythme sera construit. Avec lui, nous allons suivre la position de la composition et gérer toutes les autres actions synchronisées. Pour suivre la composition, nous avons besoin de quelques 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; 

Lors du démarrage de la scène, nous devons effectuer des calculs pour déterminer les variables et également enregistrer pour référence l'heure de début de la composition.

 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 vous créez un GameObject vide avec un tel script attaché, puis ajoutez la source audio avec la composition et exécutez le programme, vous verrez que le script enregistrera l'heure de début de la composition, mais rien d'autre ne se passera. Nous devrons également saisir manuellement le BPM de la musique que nous ajoutons à la source audio.


Grâce à toutes ces valeurs, nous pouvons suivre la position dans la composition en temps réel lors de la mise à jour du jeu. Nous déterminerons le timing de la composition, d'abord en secondes, puis en fractions. Les fractions sont un moyen beaucoup plus pratique de suivre une composition, car elles nous permettent d'ajouter des actions et des synchronisations dans le temps en parallèle avec la composition, par exemple, dans les fractions 1, 3 et 5.5, sans avoir besoin de calculer des secondes entre les fractions. Ajoutez les calculs suivants à la fonction Update () de la classe 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; } 

Nous obtenons donc la différence entre l'heure actuelle selon le système audio et l'heure de début de la composition, ce qui donne le nombre total de secondes pendant lesquelles la composition est jouée. Nous l'enregistrerons dans la variable songPosition.


Notez que la partition en musique commence généralement par une unité avec les fractions 1-2-3-4 et ainsi de suite, et songPositionInBeats commence à 0 et augmente à partir de cette valeur, de sorte que la troisième partie de la composition correspondra à songPositionInBeats, qui est 2,0, pas 3,0.

À ce stade, si vous souhaitez créer un jeu traditionnel de style Dance Dance Revolution, vous devez créer des notes en fonction de la fraction sur laquelle vous devez les appuyer, interpoler leur position par rapport à la ligne de clic, puis enregistrer songPositionInBeats lorsque la touche est enfoncée, et Comparez la valeur avec la proportion souhaitée de notes. Yu Chao discute d'un exemple d'un tel schéma dans son article . Afin de ne pas me répéter, je considérerai d'autres techniques potentiellement utiles qui peuvent être construites en plus de la classe de chef d'orchestre. Je les ai utilisés lors de la création d' Atomic Beats .

Nous nous adaptons à la part initiale


Si vous créez votre propre musique pour un jeu de rythme, il est facile de faire en sorte que le premier temps corresponde exactement au début de la musique, ce qui, s'il est correctement spécifié, liera de manière fiable la chansonPositionInBeats de la classe Conductor à la composition.


Cependant, si vous utilisez de la musique prête à l'emploi, il y a une forte probabilité qu'il y ait une légère pause avant le début de la composition. Si cela n'est pas pris en compte, alors la classe conductor songPositionInBeats pense que le premier temps a commencé lorsque la chanson a commencé à jouer, et non le temps maintenant. Tout ce qui sera davantage lié aux valeurs des partages n'est pas synchronisé avec la musique.


Pour résoudre ce problème, vous pouvez ajouter une variable qui prend en compte ce décalage. Ajoutez les éléments suivants à la classe Conductor:

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

Dans Update (), la variable songPosition:

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

remplacé par:

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

Maintenant, songPosition calculera correctement la position dans la chanson, en tenant compte du vrai premier temps. Cependant, vous devrez entrer manuellement le décalage par rapport au premier temps, donc pour chaque fichier, il sera unique. De plus, pendant ce décalage, il y aura une courte fenêtre dans laquelle songPosition se révélera négatif. Cela peut ne pas affecter le jeu, mais certains codes, en fonction des valeurs de songPosition ou songPositionInBeats, peuvent ne pas être en mesure de traiter les nombres négatifs pour le moment.



Répétitions


Si vous travaillez avec une composition qui se joue du début à la fin, la classe de chef d'orchestre montrée ci-dessus sera suffisante pour suivre la position. Mais si vous avez une courte piste en boucle et que vous souhaitez travailler avec cette boucle, vous devez intégrer la prise en charge de Repeater dans Conductor.

Si vous avez un fragment parfaitement bouclé (par exemple, si le tempo du morceau est de 120 bpm et que le fragment bouclé a une longueur de 4 temps, il devrait être exactement 8,0 secondes à 2,0 secondes par partage) chargé dans la classe Audio Source de la classe Conductor, puis cochez la case de boucle. Le chef d'orchestre fonctionnera de la même manière qu'auparavant et transférera la durée totale à songPosition après le premier démarrage du clip. Pour déterminer la position de la boucle, nous devons en quelque sorte dire à Conductor combien de partages se trouvent dans une boucle et combien de boucles ont déjà été jouées. Ajoutez les variables suivantes à la classe 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; 

Maintenant, avec chaque mise à jour de SongPositionInBeats, nous pouvons également mettre à jour la position Update () de la boucle.

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

Cela nous donne un marqueur qui indique à loopPositionInBeats combien de partages nous avons parcourus la boucle, ce qui est utile pour de nombreux autres éléments synchronisés. N'oubliez pas de saisir le nombre de partages de la boucle dans GameObject Conductor.

Nous devons également examiner attentivement le calcul des actions. La musique commence toujours à 1, donc la mesure en 4 parties prend la forme 1-2-3-4-, et dans notre classe loopPositionInBeats commence à 0,0 et boucle sur 4,0. Par conséquent, le milieu exact de la boucle, qui lors du calcul des proportions musicales sera 3, dans loopPositionInBeats aura une valeur de 2,0. Vous pouvez modifier loopPositionInBeats pour en tenir compte, mais cela affectera tous les autres calculs, soyez donc prudent lors de l'insertion de notes.

Pour les autres outils, il sera également utile d'ajouter deux autres aspects à la classe Conductor. Tout d'abord, une version analogique de LoopPositionInBeats appelée LoopPositionInAnalog, qui mesure la position dans la boucle dans la plage de 0 à 1,0. La seconde est une instance de la classe Conductor pour des appels pratiques à partir d'autres classes. Ajoutez les variables suivantes à la classe 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; 

Dans la fonction Awake (), ajoutez:

 void Awake() { instance = this; } 

et ajoutez à la fonction Update ():

 loopPositionInAnalog = loopPositionInBeats / beatsPerLoop; 

Turn Sync


Il serait très utile de synchroniser le mouvement ou la rotation avec les lobes afin que les éléments soient aux bons endroits. Dans mon jeu Atomic Beats, je l'ai utilisé pour faire pivoter dynamiquement les notes autour d'un axe central. Initialement, ils ont été placés autour de la circonférence en fonction de leur part à l'intérieur de la boucle, puis toute la zone de jeu a été tournée de sorte que les notes correspondent à la ligne de dépression de leur part.

Pour ce faire, créez un nouveau script appelé SyncedRotation et attachez-le au GameObject que vous souhaitez faire pivoter. Ajoutez à la fonction Update () du script SyncedRotation:

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

Ce code interpolera la rotation du GameObject auquel ce jeu est lié dans l'intervalle de 0 à 360 degrés, en le tournant de manière à ce qu'il effectue un tour complet à la fin de chaque boucle. Ceci est utile à titre d'exemple, mais pour les animations en boucle ou image par image, il serait plus utile de synchroniser les animations en boucle afin qu'elles correspondent parfaitement au tempo.

Synchronisation d'animation


Unity Animator est extrêmement puissant, mais pas toujours précis. Pour un alignement fiable des animations et de la musique, j'ai dû rivaliser avec la classe Animator et sa tendance à se désynchroniser progressivement avec le rythme. De plus, il était difficile d'ajuster les mêmes animations à des tempos différents, de sorte que lors du basculement entre les compositions, vous n'aviez pas à redéfinir les images clés de l'animation au tempo actuel. Au lieu de cela, nous pouvons aller directement à la boucle d'animation et définir la position dans cette boucle en fonction de notre position dans la boucle de la classe Conductor.

Créez d'abord une nouvelle classe appelée SyncedAnimation et ajoutez-y les variables suivantes:

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

Attachez-le à un GameObject nouveau ou existant que vous souhaitez animer. Dans cet exemple, nous allons simplement déplacer l'objet d'avant en arrière sur l'écran, mais le même principe peut être appliqué à n'importe quelle animation, que ce soit avant de définir la propriété, ou animation image par image. Ajoutez un élément Animator à GameObject et créez un nouveau contrôleur Animator appelé SyncedAnimController, ainsi qu'un clip d'animation appelé BackAndForth. Nous chargeons le contrôleur dans la classe Animator attachée au GameObject et ajoutons Animation à l'arborescence d'animation comme animation par défaut.


Par exemple, j'ai configuré l'animation de sorte qu'elle déplace d'abord l'objet vers la droite de 6 unités, puis vers la gauche de -6, puis de nouveau à 0.


Maintenant, pour synchroniser l'animation, ajoutez le code suivant à la fonction Start () de la classe SyncedAnimation, qui initialise les informations sur l'animateur:

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

Ajoutez ensuite le code suivant à Update () pour définir l'animation:

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

Nous positionnons donc l'animation dans le cadre exact de l'animation par rapport à une boucle complète. Par exemple, si vous utilisez l'animation ci-dessus, lorsque vous êtes au milieu de la boucle, la position GameObject ne fera que croiser 0. Cela peut être appliqué à toute animation que vous créez que vous souhaitez synchroniser avec le tempo du conducteur.

Il convient également de noter que pour créer une boucle transparente d'animations, vous devez configurer les tangentes des images clés individuelles de l'animation sur la courbe d'animation. Le paramètre Linéaire créera une ligne droite allant d'une image clé à la suivante, et Constant conservera l'animation dans une valeur jusqu'à l'image clé suivante, ce qui donnera un mouvement saccadé et net.


Bien que cette méthode soit utile, elle affecte toutes les transitions de l'animation, car elle oblige animationState à rester dans l'état dans lequel elle se trouvait lors de l'exécution initiale du script. Cette méthode est utile pour les objets qui n'ont besoin que d'une animation synchronisée sans fin, mais pour créer des objets plus complexes avec différentes animations synchronisées, vous devez ajouter du code qui traite ces transitions et définit la variable currentState en fonction de l'état d'animation souhaité.

Conclusion


Ce ne sont là que quelques-uns des aspects qui m'ont été utiles dans la création d'Atomic Beats. Certains d'entre eux ont été collectés à partir d'autres sources ou créés par nécessité, mais la plupart d'entre eux je n'ai pas pu trouver sous la forme finie, alors j'espère que cela sera utile! Peut-être qu'une partie de mon système ne sera plus utile dans les grands projets en raison des limitations du processeur ou du système audio, mais ce sera une bonne base pour jouer à un jam de jeu ou à un projet de loisir.

image

La création d'un jeu de rythme ou d'éléments de jeu synchronisés avec la musique peut être difficile. Pour garder tout à un rythme constant, vous aurez peut-être besoin d'un code délicat; un résultat qui vous permet de jouer à un rythme constant peut être très attrayant pour le joueur. Beaucoup plus peut être fait dans ce genre que les jeux dans le style traditionnel Dance Dance Revolution, et j'espère que cet article vous aidera à réaliser de tels projets. Je recommande également, si possible, d'évaluer mon jeu Atomic Beats . Je l'ai fait en un mois au printemps de cette année, il a 8 pistes courtes et c'est gratuit!

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


All Articles