Blog
Porter un jeu SDL sur Android (2/2)
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 :
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 typeSDL_MOUSEBUTTONDOWN
avec les valeursev.button.x == 960
etev.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 typeSDL_FINGERDOWN
avec les valeursev.button.x == 0.5
etev.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 valeurANDROID_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 aussiSDL_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
etSDL_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énementSDL_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 !