Porter un jeu SDL sur Android (2/2) | Blog | Superflu Riteurnz

Porter un jeu SDL sur Android (2/2) | Blog | Superflu Riteurnz Porter un jeu SDL sur Android (2/2) | Blog | Superflu Riteurnz
  • Steam
  • Nintendo Switch
  • Itch.io
  • Google Play
Superflu et son assistante Sophie

Blog

Porter un jeu SDL sur Android (2/2)

2021-10-20

La dernière fois, j'ai abordé tout l'aspect technique de la « mise en place » d'un environnement Android pour faire tourner notre jeu : configuration, compilation, etc.

Maintenant, on rentre dans le vif du sujet : qu'est-ce que ça change, côté code, de porter un jeu SDL2 sur Android ?

Débuguer une app Android

Quand on code, on arrive rarement au résultat voulu du premier coup, il y a toujours des bugs à trouver et à corriger, et il est certain que porter un jeu sur Android ne fera pas exception ! Alors certes, vu qu'on débugue déjà le problème sur Gnunnux, on limite les chances d'avoir des bugs sur Android, mais on n'est jamais à l'abri d'erreurs spécifiques à une plateforme ou une autre…

Alors, autant il est facile de débuguer une application sur un ordinateur avec des messages sur une console (ou, mieux, un débugueur), autant récupérer les éventuels messages depuis un dispositif Android demande un peu plus de boulot.

Fort heureusement, plusieurs outils sont à notre disposition. Tout d'abord, pour éviter de systématiquement devoir se munir d'un dispositif Android (téléphone ou tablette) externe, on peut utiliser l'émulateur fourni avec le SDK :

emulator

Le logiciel adb, fourni aussi avec le SDK, permet de facilement installer des apps en ligne de commande, soit vers l'émulateur, soit vers un dispositif connecté à l'ordinateur. Ainsi, au lieu de devoir transférer à la main l'APK sur une tablette et l'installer ensuite, on peut simplement faire :


adb install --user 0 app/build/outputs/apk/debug/app-debug.apk

Le flag --user 0 est optionnel et permet de n'installer l'app que pour le premier utilisateur.

Petit bonus : on peut même carrément se connecter à une tablette via le réseau Wi-Fi local, pour ne même pas s'embarrasser d'un câble… Ensuite, on peut récupérer les informations envoyées sur une sorte de « console » par les différents programmes via adb logcat.

Tout ça c'est très bien, mais si dans votre code, vous faites de simples appels std::cout ou std::cerr, c'est la déception qui vous attend : rien ne s'affichera dans adb logcat. Les sorties standards et d'erreur ne sont en effet pas répercutées dans le log Android, il faut donc passer par une fonction spécifique, accessible via l'entête #include <android/log.h>. Histoire de pouvoir jongler facilement entre les sorties habituelles et la sortie Android, on peut passer par une fonction dédiée debug() et changer la définition à la compilation, via le préprocesseur, selon le système cible :


#if defined(SOSAGE_ANDROID)
inline void debug (const std::string& str)
{
  __android_log_print (ANDROID_LOG_INFO, "Sosage Info", "%s", str.c_str());
}
#else
inline void debug (const std::string& str)
{
  std::cerr << str << std::endl;
}
#endif

Notez qu'on pourrait même se payer le luxe de définir un opérateur de stream perso (pour pouvoir faire quelque chose du style My_stream << "Debug" << std::endl), mais j'avoue que j'avais un peu la flemme… Ceci dit, il est fort possible que je m'y colle prochainement, parce que ce serait tout de même bien pratique !

Un point bien pratique : le second argument, ici "Sosage", sera répercuté en début de ligne à chaque appel à la fonction, ce qui va permettre, en faisant adb logcat | grep Sosage par exemple, de ne récupérer que l'output de notre programme. Oui, parce que adb logcat affiche tout ce qui se passe sur votre Android, et ça peut vite devenir très verbeux !

Accéder aux données

Comme je l'ai expliqué au niveau du CMakeLists, Android ne permet pas aux applis d'accéder directement au système de fichier. À la place, il faut passer par un gestionnaire (asset manager) : de la même manière qu'on ne peut pas utiliser std::cerr pour écrire des infos dans la console, on ne peut pas utiliser les habituels std::ifstream/std::ofstream pour lire et écrire des fichiers.

Encore une fois, SDL nous sauve la vie en s'occupant de gérer cet aspect d'Android : en effet, l'API de gestion des fichiers de la SDL (comme SDL_LoadImage()) va, de manière transparente, passer du système de fichier classique sur Gnunux et cie à l'asset manager sur Android (ainsi qu'à d'autres variantes, un jour je vous parlerai par exemple d'Emscripten qui permet de porter le jeu dans un navigateur lambda). Et comme, en plus des fonctions dédiées à la lecture d'image, SDL propose des fonctions générales pour ouvrir/lire/écrire des fichiers, il suffit d'utiliser cette API pour que tout fonctionne bien sur Android également ! C'est tout simple, au lieu de faire :


std::ifstream mon_fichier (SOSAGE_DATA_FOLDER + "/mon_fichier.peuimporte");
int ma_donnee;
mon_fichier >> ma_donnnee;
mon_fichier.close();

On va faire :


SDL_RWops* mon_fichier = SDL_RWFromFile(SOSAGE_DATA_FOLDER + "/mon_fichier.peuimporte");
int ma_donnee;
SDL_RWread(mon_fichier, &ma_donnee, sizeof(int), 1);
SDL_RWclose(mon_fichier);
// ...

L'API est un peu plus verbeuse et moins moderne (c'est du C), mais on peut facilement wrapper tout ça dans une API plus haut niveau de notre choix. Et voilà !

Gestion du tactile et des événements Android

Qui dit Android dit, en général, écran tactile. Et ça tombe bien : la SDL fournit tout ce qu'il faut pour gérer ces événements ! Notons que ce n'est pas spécifique à Android : vous pouvez très bien utiliser un écran tactile sur un autre OS, et vous pouvez aussi brancher une souris sur votre tablette…

Ainsi, on va principalement utiliser 3 événements :

  • SDL_FINGERDOWN, qu'on reçoit lorsqu'un doigt est posé sur l'écran ;
  • SDL_FINGERMOTION, reçu lorsque ce doigt bouge ;
  • SDL_FINGERUP, reçu lorsque le doigt quitte l'écran.

C'est en soi assez similaire à la gestion de la souris qui a 3 événements équivalents (SDL_MOUSEBUTTONDOWN, SDL_MOUSEMOTION et SDL_MOUSEBUTTONUP), avec une petite subtilité tout de même :

  • les événements de la souris sont donnés avec des coordonnées entières correspondant aux nombres de pixels parcourus depuis l'origine en haut à gauche de la fenêtre. Si je clique au milieu de fenêtre HD 1920x1080, alors mon programme reçoit un événement ev de type SDL_MOUSEBUTTONDOWN avec les valeurs ev.button.x == 960 et ev.button.y == 540 ;
  • pour une raison que j'ignore, les événements tactiles donnent eux des coordonnées flottantes correspondant à la fraction de la fenêtre parcoure depuis l'origine en haut à gauche. Ainsi, si je clique au milieu de ma fenêtre, mon programme reçoit un événement ev de type SDL_FINGERDOWN avec les valeurs ev.button.x == 0.5 et ev.button.y == 0.5. Il suffit donc de multiplier ces valeurs respectivement par la largeur et la hauteur de votre fenêtre pour retomber sur les mêmes valeurs qu'avec un clic de souris, mais il faut le savoir !

Autre détail qui peut s'avérer assez ennuyant si, comme moi, vous voulez gérer à la fois la souris et le tactile indépendemment de l'OS : les événements tactiles génèrent aussi… de faux événements de souris. C'est un choix technique étrange, mais en gros, lorsque vous touchez l'écran, le programme reçoit à la fois un événement SDL_FINGERDOWN ET un événement SDL_MOUSEBUTTONDOWN. Même chose pour le mouvement et la levée du doigt. J'imagine que si vous ne voulez pas vous soucier du mode d'input et que vous gérez la souris exactement de la même manière que le tactile, ça peut être très pratique (on peut complètement ignorer les événements tactiles et interpréter uniquement les événements souris)… dans mon cas, le mode de jeu sera assez différent, et ce comportement est plutôt gênant.

J'ai résolu le problème en vérifiant juste, lorsque je reçois un événement souris, s'il n'y a pas également un événement tactile au même instant, auquel cas j'ignore l'événement souris. Encore une fois, rien de bien compliqué, mais il faut le savoir…

Parlons pour finir de quelques événements spécifiques à Android et qui peuvent être bien pratique :

  • un événement de type SDL_KEYDOWN (touche pressée) avec la valeur ANDROID_BACK correspond à l'appui sur la touche « retour » de votre dispositif Android : pratique pour, par exemple, afficher le menu du jeu, passer une cinématique, etc. Je la traite un peu comme la touch « Echap » sur un clavier ;
  • SDL_APP_WILLENTERBACKGROUND est générée lorsque l'application est sur le point de passer en arrièr-plan (par exemple, si la touche pour revenir à l'accueil d'Android a été pressée) : dans mon cas, ça me permet de mettre le jeu en pause. On a aussi SDL_APPDIDENTERBACKGROUND qui est générée lorsque l'application est effectivement passée en arrière-plan (la différence est subtile) ;
  • de la même manière, on a SDL_APP_WILLENTERFOREGROUND et SDL_APP_DIDENTERFOREGROUND pour vous prévenir que l'app va revenir (ou est revenue) au premier plan : dans mon cas, c'est la fin de la pause, le jeu reprend normalement ;
  • SDL_APP_TERMINATING qui vous prévient que l'application s'arrête, et vous laisse donc sauvegarder ce qu'il faut et quitter proprement l'appli (on peut voir ça comme l'équivalent de l'appui sur la croix dans la version bureau, soit l'événement SDL_QUIT).

Avec ces différents événements, on peut maintenant gérer toutes les interactions possibles avec un dispositif Android lambda !

Accès à l'interface Java

Un dernier point technique, assez optionnel mais qui peut avoir son utilité : l'accès à l'interface Java. En effet, techniquement, l'application est un programme Java dans lequel votre programme C++ va tourner. Ce à quoi nous donne accès la SDL est déjà très complet, mais en réalité, le SDK Android permet d'accéder à des choses beaucoup plus bas niveau lorsqu'on développe une app classique. Pour cette raison, le NDK Android offre une API pour appeler du code Java depuis le code C++ par l'intermédiaire du JNI (Java Native Interface).

Par exemple, dans mon cas, j'ai eu besoin de cet outil au moment de m'occuper de la traduction français/anglais du jeu : on peut bien sûr choisir la langue depuis le menu du jeu, mais au moment où le jeu se lance pour la première fois, c'est tout de même mieux si le jeu se lance dans la langue du système. Pour cela, sur Gnunux, j'utilise std::locale("").name() qui, sur mon ordinateur en français, me renvoie fr_FR (soit « français de France »). Un ordinateur américain dira en_US, etc.

Malheureusement, en compilant pour Android, std::locale ne vous renverra rien, la feature n'est pas disponible. On peut détecter la langue, mais seulement depuis l'interface Java. C'est là que le JNI intervient ! Je vous passe les détails techniques, mais on a besoin de récupérer l'environnement Java et l'activité (l'application) courante. SDL nous donne ces accès via SDL_AndroidGetJNIEnv() et SDL_AndroidGetActivity(). À partir de là, on peut utiliser les fonctions du JNI comme on veut !

En pratique, dans le code Java, on aura la détection de la langue :


import java.util.Locale;

// (...)

// Dans la classe SDLActivity
public static String locale = "";
public static String getLocale()
{
  return locale;
}

// Dans le constructeur
String lang = new String(Locale.getDefault().getLanguage());
if (Locale.getDefault().getCountry().length() > 0)
{
  lang = lang + "_" + Locale.getDefault().getCountry();
}
locale = lang;

Et en C++, l'appel à la fonction Java :


#if defined(SOSAGE_ANDROID)
#  include <jni.h>
#else
#  include <locale>
#endif

std::string get_locale()
{
#if defined(SOSAGE_ANDROID)
  JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
  jobject activity = (jobject)SDL_AndroidGetActivity();
  jclass jni_class(env->GetObjectClass(activity));
  jmethodID method_id = env->GetStaticMethodID(jni_class, "getLocale", "()Ljava/lang/String;");
  jstring jstr = (jstring) env->CallStaticObjectMethod(jni_class, method_id);
  const char *str = env->GetStringUTFChars(jstr, 0);
  std::string out (str);
  env->ReleaseStringUTFChars(jstr, str);
  env->DeleteLocalRef(jstr);
  env->DeleteLocalRef(jni_class);
  return out;
#else
  return std::locale("").name();
#endif
}

Et voilà ! On pourrait faire ça pour n'importe quelle méthode de n'importe quelle classe qui nous intéresse.

Conclusion

En pratique, porter un jeu SDL2 sur Android ne se fait pas en claquant des doigts : ça demande pas mal d'adaptations, que ce soit au niveau de la compilation, des événements, de la gestion des données et de certains appels système.

Mais déjà, la SDL nous mâche beaucoup de travail et fournit un paquet d'outils très pratique. Ensuite, c'est un boulot qu'on va devoir principalement faire une fois : une fois qu'on a mis en place ces différents mécanismes, ça marche, ça ne bouge pas, et on peut développer tranquillement notre jeu en C++ sans se soucier de ce bazar.

Dans de prochains articles, je vous parlerai sans doute du portage sur Emscripten (qui permet de jouer au jeu dans un navigateur) ou de la cross-compilation vers Windows depuis Gnunux. À bientôt !

i