Vertical

Développer un jeu de rythme avec Unity : Les pièges à éviter

Les jeux de rythme sont un genre particulier de jeu vidéo où le joueur doit faire des actions précises suivant un rythme donné ou, plus généralement, une musique.

Cependant, faire un jeu de rythme sur quelque plateforme que ce soit n’est pas une tâche si simple que ça. Les contraintes de synchronisation avec la musique ou demander à l’utilisateur un appui « précis » dans le temps sont difficiles à résoudre. Beaucoup de jeux résolvent ces problèmes en les ignorant, amenant à un mauvais jeux de rythme.

Si vous voulez vous lancer dans le développement d’un jeu de ce type sur mobile (mais aussi sur PC !), voici ce à quoi vous devez penser.

L’importance du framerate

Le facteur framerate n’est souvent pas mis en avant dans le secteur du jeu mobile. Si un jeu tourne correctement pour le commun des mortels (c’est-à-dire > 25 FPS), il y a généralement peu d’intérêt à passer la barre des 30 FPS comme sur PC. Mais pour les jeux de rythme l’importance du framerate est très différente, elle est étroitement liée à votre gameplay.

En effet, dans un jeu de rythme le joueur doit appuyer sur l’écran pour valider une note à un moment précis dans le temps. Pour cela il a ce qu’on appelle une « fenêtre de temps » pour décider si l’action est valide ou non.

Prenons l’exemple de jeux de rythme populaires : Dans Guitar Hero 3, les notes défilent de haut en bas de l’écran, et lorsqu’une note arrive sur les marques grises en bas, le joueur a alors une fenêtre de temps d’environ 100 millisecondes pour valider sa note. Cela veut dire que l’utilisateur a 100 millisecondes avant et après le moment exact ou il est censé taper pour valider. En dehors cette fenêtre la note est considérée comme ratée.

Dans Osu! ou Stepmania, 2 jeux de rythme PC très « hardcore gamer », il existe en plus des appréciations en fonction de votre précision (bien, excellent, parfait, …). La note parfaite s’obtient si l’utilisateur appuie dans une fenêtre d’environ 20 millisecondes.

Quel est le problème ?

Un jeu vidéo fonctionne par frame. Le framerate indique combien de fois votre code va tourner dans la fonction Update() en une seconde. Vous pouvez récupérer l’appui d’un utilisateur en faisant par exemple appel à la méthode GetMouseButtonDown() dans la méthode Update().

Cette méthode a pour but de vérifier si un bouton de la souris a été pressé entre la frame en cours et la frame d’avant :

  • La frame T commence.
  • Un certain temps s’écoule… (30FPS = 33ms d’attente par exemple)
  • La frame T+1 commence : GetMouseButtonDown() vérifie les appuis utilisateurs produits entre la frame T et la frame T+1.

Voici donc le lien avec le framerate : Il existe une zone de « non-réaction » des appuis utilisateurs entre les frames. Si vous êtes à 30FPS, cela veut dire qu’il y a 33 millisecondes d’attente entre chaque frame, chaque méthode Update(). Cela a pour conséquence que si un utilisateur appuie sur l’écran, il peut se passer entre 0 et 33 millisecondes d’attente pour que cet appui soit pris en compte ! On dit que la marge d’erreur est de 33ms.

(Plus d’infos sur les appuis utilisateurs ici)

En quoi cette marge d’erreur est-elle gênante?

Dans un jeu comme Osu! ou la fenêtre est très fine (20ms), 33ms de marge d’erreur est inadmissible : Si l’utilisateur appuie parfaitement sur la note, la marge d’erreur étant plus grande que la fenêtre, il peut rater son « parfait » alors qu’il n’y est pour rien ! C’est en partie la raison des échecs de la plupart des clones d’Osu! à 30FPS sur plateforme mobile.

De même que pour un jeu comme Guitar Hero à 100ms de fenêtre, si ici on peut laisser une marge d’erreur pareil (le jeu tourne d’ailleurs à 30FPS), on note que si l’utilisateur appuie 80ms trop tard et que la fenêtre est de 100ms, la marge d’erreur de 33ms peut lui faire rater la note alors qu’il était dans la bonne fenêtre d’appui (80ms + 33ms > 100ms). Souvent on considère ce cas comme la faute de l’utilisateur : il était “limite bon” donc on accepte le fait qu’il ait raté à cause de la marge d’erreur.

Il faut donc calibrer votre framerate en fonction de votre gameplay : Ne choisissez pas une fenêtre d’appui trop fine car la plateforme mobile n’est pas une plateforme très disposée à un framerate conséquent. Si vous voulez absolument faire un jeu avec une fenêtre fine, il faut changer le Application.targetFrameRate (bloqué à 30 de base sur mobile). Attention cependant, si vous calez votre jeu à 60 voir infini (en mettant Application.targetFrameRate = -1), votre jeu doit être un exemple de fluidité et vous devez être conscient de la consommation de batterie de votre jeu au dessus des 30 FPS…

Généralement, 60FPS est un compromis suffisant puisqu’il donne une marge d’erreur maximum de 16.6ms. Les fenêtres des jeux de rythme les plus exigeants tournant entre 20ms et 25ms, cela suffit. Cependant votre jeu doit être assez léger pour tenir ces 60FPS sans problèmes.

(Plus d’infos sur le targetFrameRate ici)

Attention aux idées reçues !

Vous pouvez vous dire qu’il existe plein d’endroit ou mettre la méthode GetMouseButtonDown() autre que dans la méthode Update. C’est vrai ! Vous pouvez les mettre dans :

  • OnGUI(), l’ancien système de GUI Unity.
  • FixedUpdate() dont le framerate dépend de ce que vous avez indiqué dans le menu « Edit/Project Settings/Time » dans la case « Fixed Timestep ».
  • une Coroutine qui permet d’attendre le temps que l’on veut grâce à WaitForSeconds()

Traitons les cas par cas :

  • Le cas OnGUI :

Ce cas est assez court puisque d’une part OnGUI n’est plus vraiment utilisé depuis la version 4.6 de Unity, d’autres part puisque OnGUI est un vrai gouffre en performance et en collection de mémoire. Mettre votre méthode GetMouseButtonDown() à l’intérieur de OnGUI aggravera le problème même car vous ne pouvez pas contrôler le framerate de OnGUI, il se peut même qu’il ne soit pas appelé pendant une seconde entière ! OnGUI dépend du framerate quoiqu’il en soit, c’est donc la pire des solutions.

  • Le cas FixedUpdate :

FixedUpdate est une fonction soit disant dépendante du framerate qui assure un certain nombre de frames par seconde fixe. Par exemple si vous avez mis un « Fixed Timestep » à 20ms, FixedUpdate sera toujours appelée 50 fois par secondes. L’idée reçue veut que si notre jeu tourne à 30FPS, FixedUpdate tourne à 50FPS donc si on met notre fonction GetMouseButtonDown() à l’interieur, on obtient des marges d’erreur réduites (20ms au lieu de 33ms) avec un jeu toujours à 30 FPS.

C’est faux. FixedUpdate fonctionne de cette façon : A chaque frame « normale », le FixedUpdate regarde s’il est en retard ou non dans son framerate personnel. Si non : Il est appelé une fois, si oui : alors il va appeler FixedUpdate le nombre de fois nécessaire pour rattraper son retard… mais tout ceci dans le même temps ! Il ne se passe donc toujours rien entre 2 frames « normales », ni de méthode Update() ni de méthode FixedUpdate() ! FixedUpdate dépend du framerate

  • Le cas Coroutine :

La Coroutine est une méthode spéciale qui permet « d’arrêter le code » à un certain endroit et de le reprendre plus tard. Ceci grâce à la méthode WaitForSeconds(float). L’idée reçue serait qu’on lance une Coroutine en boucle avec la méthode GetMouseButtonDown() suivant d’un WaitForSeconds(0.001f) (1ms d’attente).

Ceci ne fonctionne pas,  WaitForSeconds fonctionne de la façon suivante : A une frame donné, si j’ai assez attendu, alors j’avance, sinon, je continue d’attendre. Donc si vous mettez un WaitForSeconds(0.001f), et que votre jeu tourne à 30FPS, il attendra quand même 33ms au complet avant de passer à l’étape suivante. Les Coroutines sont dépendantes du framerate.

 En conclusion : Le cas de l’appui utilisateur précis n’a pas de solution simple. Il faut s’attaquer au multithreading et gérer les appuis utilisateurs dans un thread à part, mais c’est une façon de faire bien plus complexe. Sinon, vous êtes donc obligé d’adapter vos ambitions ou/et le framerate de votre jeu pour qu’il soit le plus précis et agréable possible !

(Plus d’infos sur l’éxecution d’une frame ici)

Synchronisation Audio/Video

L’autre point important d’un jeu de rythme est sa synchronisation avec le son.

Ce qu’il faut comprendre c’est que les téléphones (ou PC) ont tous des façons différentes de traiter le son, et certains rajoutent en plus une sur-couche logiciel pour rendre le son meilleur (par exemple les téléphones Xperia). Ces traitements qu’ils soient électroniques ou logiciels changent la latence entre le son et l’écran. Par exemple, s’il y a une explosion dans un film sur votre écran, vous n’entendrez ce son qu’au bout d’un certain nombre de millisecondes. Avec un jeu de rythme, du au fait que nous sommes dans une application (une surcouche en plus), que la musique doit être chargée, puis lue, la latence entre toutes ces opérations s’accumule et peu devenir importante, jusqu’à être visible.

Il faut donc être conscient de la façon dont vous chargez le son. Si vous synchronisez la musique et le son à la frame 1 (au Start()), la latence diffère suivant vos paramètres pour le son. Dans Unity lorsque vous cliquez sur un son dans l’Inspector, vous avez plusieurs méthodes de chargement dans « Load Type » :

  • Decompressed on load : Charge la chanson en mémoire dès que vous la chargez dans l’Audio Source : Cela crée une latence « normal » mais nécéssite une étape de loading de la musique.
  • Compressed in memory : Crée une forte latence car la musique se décompresse à la volée et en « background ». Le « Play() » ne sera donc pas joué directement mais devra attendre un certain temps avant de démarrer.
  • Stream from disc : La chanson est chargée petit bout par petit bout, ce qui crée une latence relativement faible et a l’avantage de ne pas placer toutes ces musiques directement dans le projet Unity. Les musiques peuvent être chargées depuis n’importe quel endroit du téléphone ou du PC. Donne généralement les meilleures performances en terme de latence mais pas forcément en terme de traitement. Attention : Beaucoup d’options ne sont plus accessibles, comme par exemple commencer la musique à un certain point. Ici vous ne pouvez plus que faire Play() et Stop().

Evidemment, la meilleur performance reste un son mis en mode « natif » directement envoyée dans la mémoire mais le fichier son devient alors énorme en taille, aussi bien dans le projet que dans la mémoire. A vous de voir quelles sont vos marges de manœuvres.

Dans tous les cas il n’y a pas de secret : Vous n’arriverez jamais à synchroniser parfaitement votre musique quelque soit la solution que vous prenez, pour les raisons matérielles et logicielles cités ci dessus. C’est pour cela qu’il faut toujours permettre à l’utilisateur averti de désynchroniser volontairement votre réglage de base pour l’adapter à son téléphone. Cela s’appelle généralement le « music offset » et est une option indispensable à tout bon jeu de rythme.

(Plus d’infos sur l’audio ici)

Conclusion

Pour faire un bon jeu de rythme, il faut être conscient des deux techniques primordiales abordées ici : La marge d’erreur dû à l’imprécision des appuis utilisateurs, ainsi que la synchronisation entre le son et l’écran. Cependant, ce n’est qu’une partie des problèmes que vous aurez à résoudre : Comment allez vous stocker vos musiques ? Comment allez vous programmer une “chart” de chanson (une suite d’instruction qui affiche les notes sur l’écran dans le bon timing) ?

Les jeux de rythme font partie des jeux familiaux les plus simples à comprendre, mais cela ne les empêche pas d’être d’excellents challenges techniques à réaliser !

Article similaire: La difficulté dans les jeux mobiles – Partie 1

Vous cherchez un partenaire pour réaliser un jeu vidéo ?   Vous avez un projet d’application ludique ?  Contactez-nous!

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *