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

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

Blog

Porter un jeu SDL sur Android (1/2)

2021-10-15

Très tôt dans le développement du jeu, j'ai eu dans l'idée de le porter sur Android : le style de jeu (point and click) s'y prête bien, j'ai par exemple beaucoup aimé jouer à Thimbleweed Park sur tablette (et pouvoir jouer aux vieux LucasArts sur son téléphone via ScummVM est un plaisir aussi).

C'est une des raisons qui m'ont vite poussé à passer de la SDL1 à la SDL2 qui, en plus de fournir une accélération matérielle bienvenue pour afficher des images et animations en haute résolution, supporte Android (et fournit même pas mal d'outils pour gérer cette plateforme, on va y venir).

Prérequis

En premier lieu, il faut installer deux choses :

  • le SDK Android (Software Development Kit, kit de développement logiciel), qui permet de générer des applications Android à partir de sources Java (et de tout un tas d'autres trucs dont je vais aussi parler) ;
  • le NDK Android (Native Development Kit, kit de développement natif), qui permet quant à lui d'intégrer du code dans un autre langage comme C++ dans mon cas.

Je ne vais pas donner de tuto pour ces deux points, vous en trouverez déjà pléthore sur Internet. Notez que Google promeut énormément Android Studio, qui est un IDE spécialisé pour Android. Si comme moi, vous préférez qu'on vous file des scripts et qu'on vous foute la paix sur la façon dont vous voulez programmer, vous pouvez également trouver le SDK tout seul (dans la section Commande line tools only). Pareil pour le NDK.

Une fois qu'on a installé ces deux composants, on peut commencer.

Un point important : en général, quand vous développez un programme sur Linux ou Windows, vous allez d'abord chercher à compiler votre programme. La partie « distribution » (les icones, le programme d'installation, etc.) ne vient qu'après et est indépendante.

Pour Android, ça se passe assez différemment : un fichier APK (que vous récupérez quand vous installer une appli sur Android) n'est pas un simple exécutable (comme un EXE sur Windows) mais bien un logiciel packagé et prêt à être installé en un clic. Du coup, un projet Android va nécessairement comporter pas mal de choses que vous ne verrez pas forcément dans les repos de logiciels sur d'autres plateformes : les icones encore une fois, mais aussi les métadonnées, la description du logiciel, les traductions du titre dans différentes langues, etc.

Première bonne nouvelle : la SDL nous fournit un squelette de projet déjà tout prêt à être utilisé. Il s'agit du dossier android-project qui est inclus dans les sources de la SDL2. Ce squelette contient notamment « glue » entre votre code et le hardware géré par Android, qui est un sacré boulot qu'on ne voudrait pas avoir à faire à la main… En clair : la partie Java est déjà faite, on n'aura quasiment pas à y toucher, il n'y a plus qu'à y brancher notre code C++, nos données et tout le reste.

Y'a quoi dans un APK ?

Jetons un œil à l'arborescence de cet android-project:


android-project
├── app
│   ├── build.gradle
│   ├── jni
│   │   ├── Android.mk
│   │   ├── Application.mk
│   │   ├── CMakeLists.txt
│   │   └── src
│   │       ├── Android.mk
│   │       └── CMakeLists.txt
│   ├── proguard-rules.pro
│   └── src
│       └── main
│           ├── AndroidManifest.xml
│           ├── java
│           │   └── org
│           │       └── libsdl
│           │           └── app
│           │               ├── HIDDeviceBLESteamController.java
│           │               ├── HIDDevice.java
│           │               ├── HIDDeviceManager.java
│           │               ├── HIDDeviceUSB.java
│           │               ├── SDLActivity.java
│           │               ├── SDLAudioManager.java
│           │               ├── SDLControllerManager.java
│           │               └── SDL.java
│           └── res
│               ├── mipmap-hdpi
│               │   └── ic_launcher.png
│               ├── mipmap-mdpi
│               │   └── ic_launcher.png
│               ├── mipmap-xhdpi
│               │   └── ic_launcher.png
│               ├── mipmap-xxhdpi
│               │   └── ic_launcher.png
│               ├── mipmap-xxxhdpi
│               │   └── ic_launcher.png
│               └── values
│                   ├── colors.xml
│                   ├── strings.xml
│                   └── styles.xml
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle

En principe, on ne devrait pas avoir besoin de changer les fichiers Java dans app/src/main/org/libsdl/app qui contiennent, comme je le disais, la « glue » entre votre code C++ et le portage SDL sur Android.

Les fichiers avec gradle dans le nom sont les fichiers de configuration du système de build d'Android (Gradlew). En général ils sont prêts à l'emploi, il suffit de modifier quelques variables. Par exemple, dans app/build.gradle, on trouve la ligne suivante :


applicationId "org.libsdl.app"

Qu'on prendra soin de remplacer par l'identifiant de notre application, soit ici :


applicationId "net.ptilouk.superfluous"

Identifiant de l'application qui va également être le chemin vers le fichier où va se trouver la classe Java principale de notre application, et qu'il faut créer. Ici, il s'appellera donc app/src/main/java/net/ptilouk/superfluous/superfluous.java et contiendra juste quelques lignes :


package net.ptilouk.superfluous;

import org.libsdl.app.SDLActivity;

public class superfluous extends SDLActivity { }

De même, on prend soin d'indiquer l'identifiant de l'application et le nom de l'activité (la classe qui hérite de SDLActivity) dans le fichier app/src/main/AndroidManifest.xml, ainsi que les numéros de versions qui permettront à Android de détecter si le logiciel doit être mis à jour :


package="net.ptilouk.superfluous"
android:versionCode="101000"
android:versionName="1.1.0-modern-ui"

<activity android:name="superfluous"

Les images PNG contenues dans les dossiers app/src/main/res/mipmap-* sont les icones de l'application dans différentes résolutions et qu'on peut donc remplacer par les nôtres :

icons

Le dossier app/src/main/res/values contient des chaînes de caractère, notamment le nom de l'application. Pour gérer les traductions, on peut ajouter des dossiers similaires en ajoutant le code de la langue au nom du dossier, par exemple value-fr pour le français dans mon cas.

Ainsi, app/src/main/res/values/strings.xml contient :


<resources>
    <string name="app_name">Superfluous Returnz</string>
</resources>

Tandis que app/src/main/res/values-fr/strings.xml contient :


<resources>
    <string name="app_name">Superflu Riteurnz</string>
</resources>

(Je sais, la différence de nom est subtile.)

Compiler du C++ avec bibliothèques tierces

On va passer par les fichiers .mk (Makefile) pour configurer notre application C++. Celui qui gère notre application C++ est app/jni/src/Android.mk et contient, à l'origine, ça :


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := main

SDL_PATH := ../SDL

LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(SDL_PATH)/include

# Add your application source files here...
LOCAL_SRC_FILES := YourSourceHere.c

LOCAL_SHARED_LIBRARIES := SDL2

LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -llog

include $(BUILD_SHARED_LIBRARY)

Tout d'abord, on veut ajouter nos fichiers sources et nos includes qui se trouvent dans un dossier stocké dans la variable SRC_PATH, on ajoute donc ces informations :


SRC_PATH := ../../../../../

LOCAL_C_INCLUDES := \
  $(LOCAL_PATH)/$(SRC_PATH)/include \
  $(LOCAL_PATH)/$(SDL_PATH)/include \

LOCAL_SRC_FILES := \
  $(wildcard $(LOCAL_PATH)/$(SRC_PATH)/src/*.cpp) \
  $(wildcard $(LOCAL_PATH)/$(SRC_PATH)/src/*/*.cpp) \
  $(wildcard $(LOCAL_PATH)/$(SRC_PATH)/src/*/*/*.cpp)

On va aussi ajouter les flags C++ dont on a besoin :


LOCAL_CPPFLAGS := -std=c++17 -frtti -O3 -DNDEBUG \
                  -DSOSAGE_DATA_FOLDER=\"data/\"

On va donc utiliser C++17, activer les optimisations du compilateur au maximum et de désactiver les informations de débuguage (ça, on peut le retirer pendant qu'on développe, bien sûr).

Le flag -frtti est un détail technique : par défaut, le NDK désactive le Run-Time Type Information (RTTI), la détection de type à l'exécution (par l'intermédiaire de dynamic_cast<>() en C++). Comme j'utilise ça assez massivement dans mon code, je le réactive avec ce flag.

La variable SOSAGE_DATA_FOLDER est utilisée pour indiquer où se trouvent les données du jeu. Sur Gnunux, par exemple, ce sera dans /usr/share/superfluous-returnz/. Sur Android, la gestion des assets (les données) est assez particulière, car le programme de « voit » qu'un dossier virtuel qui va contenir spécifiquement les données du jeu et qui correspond au dossier app/src/main/assets/. Donc ici, on va copier nos données dans app/src/main/assets/data/ et on indique donc simplement data/ comme dossier.

Au niveau des dépendances, SDL est bien sûr déjà inclus, mais il manque les composants SDL supplémentaires que j'utilise, ainsi que deux autre bibliothèques tierces, Yaml et LZ4. Je les ajoute.

Le fichier complet ressemble donc maintenant à ça :


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := main

SDL_PATH := ../SDL
SDL_IMAGE_PATH := ../SDL_image
SDL_MIXER_PATH := ../SDL_mixer
SDL_TTF_PATH := ../SDL_ttf
YAML_PATH := ../yaml
LZ4_PATH := ../LZ4
SRC_PATH := ../../../../../

LOCAL_CPPFLAGS := -std=c++17 -frtti -O3 -DNDEBUG

LOCAL_C_INCLUDES := \
  $(LOCAL_PATH)/$(SRC_PATH)/include \
  $(LOCAL_PATH)/$(SDL_PATH)/include \
  $(LOCAL_PATH)/$(SDL_IMAGE_PATH)/include \
  $(LOCAL_PATH)/$(SDL_MIXER_PATH)/include \
  $(LOCAL_PATH)/$(SDL_TTF_PATH)/include \
  $(LOCAL_PATH)/$(YAML_PATH)/include \
  $(LOCAL_PATH)/$(LZ4_PATH)/include \

LOCAL_SRC_FILES := \
  $(wildcard $(SRC_PATH)/src/*.cpp) \
  $(wildcard $(SRC_PATH)/src/*/*.cpp) \
  $(wildcard $(SRC_PATH)/src/*/*/*.cpp)

LOCAL_SHARED_LIBRARIES := SDL2 SDL2_image SDL2_mixer SDL2_ttf yaml lz4

LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -llog

include $(BUILD_SHARED_LIBRARY)

On prend soin de copier les sources (où de faire un symlink si on préfère) des différents composants SDL, de Yaml et de LZ4 vers les dossiers spécifiés. Les différents composants SDL fournissent directement des fichiers Android.mk pour la compilation vers Android, mais ce n'est pas le cas pour Yaml et LZ4. Qu'à cela ne tienne, il suffit de les écrire ! On peut largement s'inspirer de ce qui était fait pour SDL.

Pour Yaml :


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := yaml

LOCAL_C_INCLUDES := $(LOCAL_PATH)/libyaml/include $(LOCAL_PATH)/libyaml/src

LOCAL_SRC_FILES := $(wildcard $(LOCAL_PATH)/libyaml/src/*.c)

LOCAL_EXPORT_C_INCLUDES += $(LOCAL_C_INCLUDES)

LOCAL_CFLAGS := \
  -DYAML_VERSION_MAJOR=0 \
  -DYAML_VERSION_MINOR=2 \
  -DYAML_VERSION_PATCH=2 \
  -DYAML_VERSION_STRING=\"0.2.2\"

include $(BUILD_SHARED_LIBRARY)

Pour LZ4, quasiment la même chose :


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := lz4

LOCAL_C_INCLUDES := $(LOCAL_PATH)/lz4/lib

LOCAL_SRC_FILES := $(wildcard ${LOCAL_PATH)/lz4/lib/*.c)

LOCAL_EXPORT_C_INCLUDES += $(LOCAL_C_INCLUDES)

include $(BUILD_SHARED_LIBRARY)

Et voilà ! On peut maintenant compiler notre application en une commande, à la racine :


./gradlew assemble

À suivre

On a maintenant une application qui compile, et c'est déjà pas mal… mais maintenant, comment on gère Android côté code ? C'est ce qu'on verra la prochaine fois !

i