Making-of – Les bases d’un jeu multiplateforme

Making-of – Les bases d’un jeu multiplateforme

Ce making-of présente des aspects techniques du développement du jeu Superflu Riteurnz (en cours de réalisation).

Le but

Dès le début du développement du jeu, il m’a semblé évident qu’il devait être multiplateforme : il devait au moins fonctionner sur Gnunux (logique, c’est l’OS que j’utilise), sur Windows (l’OS le plus utilisé sur ordinateur de bureau / portable) et sur Android (l’OS le plus utilisé sur tablettes et téléphones). Les dérivés comme LineageOS sont compris dans « Android ». Ajoutons à cela un portage web pour pouvoir y jouer directement dans le navigateur.

Je vais donc vous expliquer comment je me suis débrouillé pour arriver à créer ces différentes versions en utilisant au maximum la même base de code (à part quelques adaptations, on va y revenir).

Ce making-of sera en plusieurs parties : cet article est une introduction, je ferai ensuite un article spécifique pour le portage Android, pour la gestion des assets, la cross-compilation, etc.

Prérequis & outils

Bon, ça peut sembler évident, mais pour faire un jeu multiplateforme, il vaut mieux utiliser des outils eux-même multiplateformes… Dans notre cas :

  • le langage C++ est assez largement supporté par tout un tas de plateformes. Pour Android (qui utilise nativement un langage basé sur Java), le NDK permet de développer des applications en C++, et pour la version web, c’est Emscripten qui va traduire notre code C++ en applet Javascript.

  • les bibliothèque utilisées : la bibliothèque standard du C++ (STL) bien entendu, mais aussi la SDL 2.0 (également portée sur Android et sur Emscripten) et, pour la lecture des fichiers, la Libyaml (une bibliothèque C relativement simple et indépendante). Cette dernière n’est pas directement portée sur Android et Emscripten, mais on verra que c’est relativement simple à faire.

  • pour la compilation en elle-même, je me repose en grande partie sur CMake, qui permet de gérer efficacement les compilateurs natifs de chaque plateforme ainsi que les dépendances. L’exception étant Android, pour lequel on va utiliser Gradle, le système de build habituel d’Android : notons que Android est assez particulier à ce niveau, parce que ce n’est pas « juste » une compilation mais bien un packaging complet du logiciel (= on produit le APK qui va être utilisé pour l’installer sur votre téléphone/tablette).

CMake, préprocesseur & constexpr

Comme je l’ai dit en intro, on va faire en sorte que la base de code reste au maximum la même (éviter au maximum d’avoir des sections de code dédiées à Android, Windows, etc.), mais chaque plateforme ayant des spécificités, on va être quand même devoir gérer ces spécificités. Si on distingue trois phases.

D’abord, la configuration : c’est le moment où on gère les dépendances avec CMake (ou gradle), où on choisit la façon dont va être organisé le logiciel (dans quels répertoires vont les données, l’exécutable, etc.). Tout ça varie pas mal selon les plateformes, et CMake permet de faire des règles spécifiques selon les cas, par exemple, pour détecter si on se trouve sur Gnunux :

if (CMAKE_SYSTEM_NAME STREQUAL Linux)
   ## config spéficique à Gnunux
elseif (CMAKE_SYSTEM_NAME STREQUAL Windows)
   ## config spéficique à Windows
endif()

(Oui, pour une raison que j’ignore, CMake appelle Gnunux « Linux », étrange non ?)

Là, par exemple, on peut dire à Gnunux d’installer l’exécutable dans /usr/local/bin (ou /usr/local/games, j’y reviendrai…) et à Windows de le mettre dans C:\Program Files\Mon_jeu.

Ensuite, vient la phase de compilation : on crée l’exécutable pour la plateforme voulue (un .exe sur Windows, un fichier sans extension sur Gnunux).

Ici, chaque système a l’amabilité de définir des macros C++ pour signaler qu’on se trouve sur ce système. Comme c’est un peu le bazar (vous pouvez toujours courir pour avoir un nommage normalisé), je me fais un petit fichier platform.h pour avoir des constantes avec un nom uniformisé :

namespace Sosage::Config
{

#if defined(__ANDROID__)
#define SOSAGE_ANDROID
constexpr bool android = true;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = false;

#elif defined(__APPLE__)
#define SOSAGE_MAC
constexpr bool android = false;
constexpr bool mac = true;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = false;

#elif defined(_WIN32)
#define SOSAGE_WINDOWS
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = true;
constexpr bool gnunux = false;
constexpr bool emscripten = false;

#elif defined(__linux__)
#define SOSAGE_GNUNUX
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = true;
constexpr bool emscripten = false;

#elif defined(__EMSCRIPTEN__)
#define SOSAGE_EMSCRIPTEN
constexpr bool android = false;
constexpr bool mac = false;
constexpr bool windows = false;
constexpr bool gnunux = false;
constexpr bool emscripten = true;

#endif

} // namespace Sosage::Config

(Oui, j’ai aussi prévu l’éventuel portage sur Mac, pareil, j’y reviendrai.)

Notez qu’ici, j’ai utilisé deux fonctionnalités du C++ : le préprocesseur via les commandes de type #define SOSAGE_GNUNUX, et les constexpr comme constexpr bool gnunux = true. Ce sont deux façon de définir des choses à la compilation (et non à l’exécution), les constexpr étant en quelque sorte la façon « moderne » (depuis C++11) tandis que les directives de préprocesseurs sont héritées du C. Les deux sont complémentaires : les constexpr permettent d’écrire un code clair qui est exactement le même code que celui exécuté… à l’exécution (avec des erreurs de compilation similaires), mais elles ne permettent pas certaines choses comme désactiver des fonctions entières.

Par exemple, si je veux gérer le fait que le caractère de séparation de répertoires est / sur tous les systèmes sauf sur Windows qui utilise \, je peux l’écrire « à l’ancienne » avec une directive de préprocesseur :

#ifdef SOSAGE_WINDOWS
char folder_separator = '\\'; // oui, il faut l'échapper, donc on écrit bien \\ et pas seulement \
#else
char folder_separator = '/';
#endif

Mais c’est bien plus lisible si je l’écris avec une constexpr :

constexpr char folder_separator = (Config::windows ? '\\' : '/');

Dans ce cas, comme Config::window est une constexpr, la condition n’est évaluée qu’une fois à la compilation (et non à chaque exécution).

Dans les deux cas, si je veux ensuite accéder à un répertoire sans me soucier de la plateforme, je n’ai qu’à faire :

std::string folder = "dossier1" + folder_separator + "sousdossier1";

Petite subtilité : j’ai pris soin de tester si la plateforme était Android en premier. En effet, Android étant basé sur Gnunux, le système définie aussi la macro _linux_ : si on veut donc distinguer Android d’un Gnunux de base, il faut donc d’abord tester si on est sur Android, et dans le cas où on ne l’est pas, tester si on est sur Gnunux.

Enfin, la dernière étape est l’exécution : à ce niveau, on ne devrait normalement plus avoir aucun code qui soit spécifique à une plateforme. En effet, l’exécutable généré n’est utilisable que sur une plateforme : il est inutile qu’un exécutable Windows contienne des instructions et des algorithmes dédiés à Android, puisque ces outils ne seront alors jamais utilisés (et ne feront donc que gonfler la taille de l’exécutable inutilement). D’où l’idée de tout faire soit à la configuration, soit à la compilation !

Conclusion

Voilà, on a préparé un environnement propre pour pouvoir développer à la fois une base de code commune ET gérer les cas particuliers nécessaires pour chaque plateforme.

Petites précisions sur les portages : a priori, le jeu est compatible avec Mac OS. Seulement, il ne semble pas possible de générer un exécutable Mac OS sans posséder un Mac, ce qui n’est bien sûr pas mon cas (et j’ai pas la moindre envie d’en acheter un, au passage). Comme le dit l’adage : « si c’est pas testé, c’est cassé », donc je doute que le jeu fonctionne sur Mac puisque je ne l’ai jamais testé, mais théoriquement rien ne s’y oppose.

Pour Windows, il existe des outils de cross-compilation (j’y reviendrai dans un article dédié), donc j’ai pu me débrouiller.

Un dernier point : la SDL a été aussi portée sur Nintendo Switch, et je vous avoue que ça me botterait bien de porter le jeu sur la console ! Là par contre, la barrière est plus administrative que technique : le SDK de Nintendo est privé, et il faut faire une demande pour y avoir accès. Autant dire qu’un embryon de jeu indépendant comme le mien n’a aucune chance d’y accéder. En revanche, possible que je tente ma chance quand le jeu sera terminé et montrable 🙂


Ce blog est sous licence libre et est donc :

  • gratuit car financé par les dons, vous pouvez me soutenir via Tipeee ou autre ;
  • librement modifiable car à sources ouvertes proposé sur mon repo Git ;
  • librement partageable via les liens ci-dessous ;
  • ouvert aux commentaires via le formulaire ci-dessous (commentaires non-publiés ‑ je ne propose aucune tribune publique – mais reçus directement sur ma boîte mail).


    Nom (obligatoire)

    Email

    Message (obligatoire)

    Antispam