Ce post a été publié sur LinuxFr.org. Vous pouvez le lire là bas, ainsi que les commentaires associés
Bonjour ‘Nal
Ce journal en six parties fera-t-il un meilleur score que le précédent ? Ou sera-t-il ex aequo ‘Nal ?
Je t’écris pour te dire que je me remets doucement à faire du jeu mobile, mais cette fois c’est juste pour le feune, juste pour me détendre en fin de journée. Je me suis dit que j’allais partager un peu cela avec toi.
Les grandes lignes
L’idée de base se construit en mélangeant les termes « PvP », « online », « ECS », « Android » ; chacun ayant sa petite justification.
Je veux faire un jeu auquel je pourrai jouer avec des amis ou de la famille, ce sera donc du Player versus Player. Nous sommes maintenant assez éloignés géographiquement, ça se fera par conséquent en ligne.
Ça fait un moment que je cherche une bonne raison de faire de l’Entity Component System. C’est l’occasion de le faire sérieusement, de jauger ce que ça vaut, et d’apprendre des trucs.
Android parce que c’est le système sur mon téléphone et je veux pouvoir jouer spontanément, sans avoir à démarrer l’ordi voire en étant loin de chez moi.
Voilà les éléments que je veux rassembler. Il n’y a rien la dedans que je n’ai jamais fait mais l’ensemble m’est nouveau, même si pas loin des expériences passées. C’est l’occasion de faire au mieux de ce que je sais faire, de reprendre ce qui peut être repris, de pousser un peu plus loin pour le connu et de découvrir pour le moins connu. Prendre des petits bouts de trucs pour les assembler ensemble, en gros, ça me plaît bien.
La techno
Côté techno j’ai pas mal hésité. Est-ce que je prends un truc que je connais bien et j’avance vite ? Est-ce que je pars dans l’inconnu et j’apprends plein de choses ? Pas évident.
Godot
J’ai un peu regardé du côté de Godot, de loin, mais les langages de scripts c’est pas mon truc ; tu casses quelque chose dans le programme, il fait mine de rien, part en prod, et s’écroule minablement.
« Bon sang mais s’il fallait 3 paramètres à cette fonction tu pouvais le dire sur ma machine, il n’y a pas de honte. Regarde ou on en est maintenant ! »
Après il n’y a pas que le GDScript dans Godot ; je peux aussi faire du C#, C++, ou C. Le support Android a l’air maintenant correct (ce n’était pas le cas quand j’ai commencé mon projet), ce qui est une bonne chose. Par contre je vois qu’ils utilisent SCons comme système de build et ça, ça ne me rassure pas du tout.
D’autre part le côté dev à la Unity ne m’emballe pas. J’ai fait l’expérience de ce dernier dans le passé, avec ses paramétrages dans l’UI, et bon sang mais quelle plaie :’( Déjà va trouver l’objet et le champ à modifier dans l’interface ; pas moyen de faire un grep. Ensuite tu modifies le champ et par le jeu des bindings et la joie du mode immédiat, ça mouline à mort pour rafraîchir le reste. Pfff j’aurais eu le temps de compiler LLVM le temps qu’Unity prenne en compte ma saisie. À côté de ça tu ajoutes les conflits dans le fichier YAML de 20 000 lignes du projet quand tu travailles à plusieurs dessus et t’imagines bien que le fun-o-mètre tombe vite à zéro.
Bref, la programmation graphique c’est pas non plus mon truc. Tu vas me dire qu’Unity n’est pas Godot, et t’as bien raison. Mes craintes sont peut-être infondées et si ça se trouve c’est hyper réactif sans avoir à débourser pour un nouveau laptop, et on peut peut-être même grepper le projet et corriger les conflits tranquillement dans le terminal ?
Ça fait quand même pas mal d’inconnues.
Bevy
J’ai aussi regardé ce qui se fait en Rust, ça serait l’occasion d’apprendre ce langage attrayant et en plus ça m’évitera qu’on me demande si j’ai envisagé de le réécrire en Rust. Une bonne chose !
De ce côté le plus prometteur serait Bevy. Bon point pour lui, c’est de l’ECS par défaut. Mauvais point pour lui, Android n’était pas supporté quand j’ai commencé (c’est supporté depuis novembre 2023).
Aujourd’hui ça serait tentant, avec une petite crainte d’essuyer les plâtres pour ce jeune projet.
SDL
Ah la SDL <3 J’adore vraiment cette lib, son côté minimaliste, sa simplicité. Je sais d’expérience qu’il est tout à fait possible de faire une application Android avec.
D’un autre côté, je sais aussi que si je prends la SDL je vais devoir tout gérer: chargement des textures, widgets, audio, inputs… C’est l’envers du minimalisme. Pour le coup j’ai juste envie de faire un jeu, pas un moteur, alors bien que ce soit tentant je vais laisser cette option de côté.
Axmol
La dernière fois que j’ai fais un jeu mobile qui a bien fonctionné j’utilisais Cocos2d-x, un moteur en C++. Du fait de cette expérience c’est une solution qui m’emballait bien. C’est un choix pragmatique, l’outil est vieillot mais il a fait ses preuves. Pas de chance, Cocos2d-x n’est plus maintenu et le projet a été transformé en Cocos Engine, un moteur/environnement de dev à la Unity. Comme pour Godot (et Unity), le développement tout graphique en mode formulaires me refroidit. Les interfaces pour comptables, très peu pour moi.
Coup de pot, la dernière version Cocos2d-x a été forkée sous le nom d’Axmol, et ça a l’air très actif. Ah-a ! Voilà qui me plaît bien :) Va pour Axmol.
Le dev
Comme je n’ai pas beaucoup de temps à accorder à mon projet, j’essaye d’aller à l’essentiel. Il faut dire que j’ai eu ma dose de projet à rallonge, « dans deux ans c’est fini », tous les ans pendant dix ans. Cette fois je prends soin d’éviter ces écueils.
Pour le mettre en une phrase : à chaque séance de dev je déverrouille un truc. Avoir un petit succès à chaque fois est essentiel pour le sentiment de progression et pour le moral. Et comme je ne peux pas faire de longues séances, je suis bien obligé de faire de petits objectifs, et donc de découper et redécouper jusqu’à ce que ce soit faisable. Pour ça todo.txt est mon ami, il ressemble à ça :
axmol:
- openal -> .a
- trop de callbacks. Instantiation et reuse?
- copy arena without alloc.
- pas moyen pour une nouvelle session de rejoindre une nouvelle partie
avec le même nom qu'une partie existante.
- client: sends update_tick(from, count)
- server wait for tick from everyone
- server runs simulation
- repeat above for each tick.
- server sends diff and last tick to clients.
+ Send diff or send other player inputs?
Ouais, on n’y comprend rien… et je ne sais pas en quelle langue me parler :D Heureusement que c’est écrit dans des langues que je comprends cela dit. T’imagines ? J’ouvre le fichier et là, paf, des notes en Suédois ! Trop galère, il faudrait que j’aille prendre des cours pour pouvoir me relire, ça me laisserait encore moins de temps pour le projet, et… heu… ouais.
En fait la réelle utilité de ce fichier n’est pas d’avoir un suivi des tâches à effectuer, c’est surtout d’avoir un endroit où vider tout ce qui me passe par la tête au fur et à mesure que j’avance. Assez naturellement les trucs essentiels se retrouvent en haut et le fond est constitué de nice to have ou de choses déjà faites par un autre biais.
Le tout début
Pour lancer le projet j’ai mis le premier jalon : une interface dans le terminal et un bonhomme (en fait un bête caractère) qui bouge en fonction des entrées du joueur. Ça me permettra de commencer à penser ECS.
Mais avant ça il me faut un truc encore plus simple : quelques fichiers à compiler. Commencer un projet de zéro n’est pas évident. Mine de rien ce n’est pas quelque chose que l’on fait tous les jours, et avec l’expérience il y a même des difficultés supplémentaires. Normal, on n’a pas envie de commencer dans une voie que l’on sait problématique. Dans mon cas j’ai passé pas mal de temps à décider de l’organisation du dépôt. Est-ce que je fais un truc à plat avec tout dans le même dossier ? Mais je sais qu’il faudra modulariser un jour, sinon ça va être le bazar. Alors est-ce que je fais un premier module comme s’il y en avait plusieurs ? Et puis je sais qu’il va y avoir des tests à plusieurs niveaux, est-ce que je gère ça tout de suite ?
Bon, il faut bien se lancer, j’ai fait un tout petit module avec l’intention de le déplacer quand le reste se concrétisera. Il n’y a même pas de quoi compiler le projet dans le premier commit ! Mais bon, ça y est, il y a du code, un commit, le projet est lancé, plus moyen de faire marche arrière.
La suite
Une fois que c’est lancé ça avance quasiment tout seul. Le second commit embarque de quoi compiler le projet et une app dans un terminal, qui ne fait pas grand chose.
Bon ben puisqu’elle ne fait rien il faut que je lui fasse faire quelque chose. Troisième commit : on affiche l’aire de jeu.
Quatrième commit : il est temps d’automatiser un peut tout cela. Ajout d’un script pour télécharger des dépendances et lancer le build.
Cinquième commit : puisqu’il y a du code, j’ajoute le formatage automatique du code.
Viennent ensuite l’ajout d’EnTT, l’affinage des options de compilation, l’ajout du build avec AddressSanitizer, un peu de gameplay, des tests…
À ce moment là j’ai posé les bases. Il y a de quoi compiler le projet, le tester, et gérer les dépendances. Dès lors je déroule des commits de gameplay, je fais un peu de nettoyage, et j’ajoute une licence. J’opte bien évidemment pour la licence libre CC-BY-NC-ND AGPL3.
Bim! dans un terminal
Le mode en ligne
À ce moment on est à environ un mois calendaire de dev, à raison d’une à deux heures quelques soirs de semaine, ça doit faire dans les 25 heures de dev… Ouais le ratio n’est pas fou fou.
Puisque j’ai maintenant une petite application dans le terminal pour tester un gameplay avec un seul joueur, il est temps de passer au multi. L’écriture du serveur et des échanges de messages n’est pas bien compliquée et avec quelques GoogleTests j’arrive à valider les bases et détecter les problèmes les plus évidents. C’est dans cette période que j’ajoute aussi un build avec ThreadSanitizer.
Le plus difficile dans le jeu en multijoueur est la synchro entre les joueurs. Pour que le jeu soit juste, et éviter les triches les plus triviales, le serveur fait tourner la simulation. Mais comme les joueurs ne peuvent pas non plus attendre le temps d’un aller retour avec le serveur à chaque cycle de jeu, ils font aussi tourner la simulation en local avec les infos qu’ils ont. Il rattrapent et réajustent ensuite leur état en fonction des messages du serveur. J’ai bien, bien, bieeeeeen, galéré à trouver une implémentation qui me convienne. Pour l’inspiration je me suis basé sur GDC17 - Overwatch Gameplay Architecture and Netcode - Timothy Ford et sur GDC18 - 8 Frames in 16ms: Rollback Networking in Mortal Kombat and Injustice 2 - Michael Stallone. Deux excellentes présentations sur le sujet, et la première parle aussi d’ECS. Un grand merci à leurs auteurs.
Au final chaque client conserve une liste d’actions effectuées depuis le dernier état envoyé par le serveur. Lorsqu’il reçoit des nouvelles de ce dernier, il supprime de cette liste tout ce qui précède le nouvel état puis rejoue le reste à la suite. Dis comme ça, ça a l’air simple (j’aurais dû implémenter le jeu en C Cool en fait). Il y a évidemment un peu de complexité par dessus ça, notamment la gestion de la défaite des joueurs. De plus, chaque client répète le dernier mouvement de chaque autre joueur, en supposant qu’il va continuer dans cette direction. En dehors de cela, de ce truc pas évident, c’est très simple :)
Pour l’instant ça a l’air de bien fonctionner, mais je suis loin des conditions réelles et ce n’est vraiment pas facile à tester.
Le mode en ligne dans un terminal.
Interface graphique
Poser les bases du mode multijoueur en ligne a pris environ deux mois. J’attaque ensuite un autre gros morceau en mettant en place une interface graphique.
Lorsque je faisais du jeu sur mobile professionnellement le développement de l’interface utilisateur était de très loin la plus grosse partie du développement. Implémenter le gameplay est une chose mais réaliser tous les menus, tous les écrans intermédiaires, avec les transitions des uns aux autres, c’est un tout autre problème. Si en plus tu veux faire ça proprement, en évitant le callback hell, les processus qui flottent en fond, les glitchs d’animations interrompues par une transition… Il faut être rigoureux.
C’est donc sans surprise que j’ai passé beaucoup plus de temps à poser les bases de cette partie que sur la précédente. Quand je dis « les bases » c’est vraiment basique : un écran avec un sprite, un bouton, une musique, et des paramètres d’affichage basés sur une sorte de fichiers de style.
Déjà qui dit sprite dit gestion des assets ; d’un côté il y a des éléments statiques tels que la musique de l’écran d’accueil, et de l’autre des ressources construites lors du build, par exemple les sprite sheets. Il faut brancher tout cela dans le build et passer les répertoires associés au lancement de l’application.
Ensuite qui dit bouton dit inputs. J’avais eu une mauvaise expérience avec les outils d’UI de Cocos2d-x, au niveau de la gestion des assets graphiques et de la saisie utilisateur, alors je n’ai pas retenté avec Axmol. Par conséquent j’ai développé un truc à part pour la saisie ainsi que de quoi faire des widgets d’interface et les composer dans des écrans. Je ne suis pas très satisfait du système de saisie mais ça fera l’affaire pour l’instant.
Une grande partie de ce développement a aussi été passée à dégrossir Axmol. Vois-tu, Axmol, de par son héritage de Cocos2d-x, contient beaucoup, beaucoup de choses. Par exemple on y trouve l’intégration de Box2D, un run-time pour Spine, de quoi charger moult types d’images et d’audio… C’est pas mal du tout mais tout cela a évidemment un prix : de nombreuses dépendances à gérer et des temps de compilation allongés. Une grande partie de ces dépendances peuvent heureusement être désactivées à coup de #define
et pour ma part je pousse jusqu’à ne compiler que les fichiers d’Axmol dont j’ai besoin.
Un dernier mot sur l’intégration d’Axmol. Comme pour beaucoup de moteurs de jeux les développeurs construisent leur logiciel dans l’idée que le produit final sera écrit dans leur moteur. On y trouve donc divers outils pour initialiser un nouveau projet, gérer des dépendances, compiler l’ensemble, etc. En particulier, il n’y a pas de moyen simple de produire un bibliothèque Axmol à intégrer dans un programme tiers. Perso ce n’est pas du tout ce que je cherche, je ne veux pas faire mon jeu dans Axmol, je veux plutôt utiliser Axmol dans mon jeu (et ce serait pareil pour d’autres moteurs tels que Godot ou Bevy, ils doivent s’ajouter à mon jeu, et non pas l’inverse). C’est pour cela que je compile Axmol à la main, de manière à en faire un brique au même titre que le reste.
L’écran d’accueuil. On est clairement sur un jeu triple A.
Android
Nous voici six mois plus tard avec une application graphique sous Linux qui se lance correctement et affiche l’écran d’accueil. C’était pas évident mais maintenant que c’est en place ça devrait le faire. Reste à faire la même chose sur mon téléphone.
Je n’étais pas pressé de revenir au dev Android, me doutant bien qu’il serait encore là, qu’il patientait sagement en attendant de pouvoir consommer toutes les ressources de mon laptop… Il s’agit bien sûr de mon vieux pote Gradle.
« Accelerate developer productivity » qu’ils disent. Lol. Et vas-y que je te download l’Internet, et vas-y que je mouline pendant vingt plombes avant de te vomir une stack trace au visage. Rhâaaaaa mais pourquoi ! « Nan mais t’as pas mis ta closure au bon endroit. RTFM man ! Qu’est-ce que tu fais avec ce briquet ? » Bon sang mais il fait cinq fois la taille de Wikipédia ton manuel !
Et voilà, je m’énerve.
Tiens, une illustration d’à quel point Gradle n’est pas fait pour le reste du monde. Quand tu fais une boucle dans ton shell pour lancer plusieurs commandes utilisant Gradle, et que ta boucle lit l’entrée standard, et bien Gradle va te consommer tout stdin et casser silencieusement ta boucle :
while read -r something
do
./gradlew someTask
done < input.txt
Peu importe le nombre de lignes dans input.txt
, une seule exécution de gradlew
sera faite, et elle lira les lignes restantes de input.txt
.
Bref, Gradle c’est pas la oij. Néanmoins, malgré ça, j’arrive à lui faire sortir une application Android qui se lance sur mon téléphone. Ça m’aura pris à peu près un mois calendaire.
Intégration continue
Me voilà maintenant avec une application qui doit tourner sur deux plates-formes (Linux et Android), qui a besoin d’une quinzaine de dépendances pour son build, et qui compile quelques dizaines de tests unitaires. Je crois qu’il est temps de mettre en place un peu d’intégration continue pour garantir une certaine qualité du projet.
Comme le code est hébergé sur GitHub, je me suis dit que c’était une bonne occasion de tester leurs Actions. Franchement, c’est pas mal du tout.
Je vise deux plates-formes donc j’opte pour le template qui va bien et je remplis ma matrice :
- système: dernière Ubuntu, et puis allez, mettons la dernière LTS au cas où on aurait un contributeur, pour ne pas imposer un système hyper récent.
- type de build: release, debug, AddressSanitizer, ThreadSanitizer ; évidemment.
- compilateur: G++, impossible de l’ignorer, et puis Clang, bien sûr.
- plate-forme cible: Linux, Android.
2 × 4 × 2 × 2 = 32 configurations testées en quelques lignes, pas mal ! Ah et puis tiens, puisque je fais des unity builds je vais quand même ajouter un build incrémental pour valider que ça marche aussi (on en a parlé lors du partage d’un excellent article). Hop 64 configs.
Bon évidemment tout est rouge et ça prend trois heures… Il est déjà temps d’élaguer. Pour commencer, ce n’est pas la peine de lancer les tests avec AddressSanitizer et ThreadSanitizer pour tous les systèmes, compilateurs, et plate-forme cibles, ni en incrémental. G++ avec la dernière Ubuntu et en ciblant Linux, ça fera l’affaire (idéalement un ciblage Android serait bien aussi mais c’est galère à mettre en place).
Même chose pour la cible Android, pas besoin de tester deux compilateurs ni deux systèmes, d’autant que ceux-là ne concernent que l’hôte.
À ce moment là ma liste d’exclusion fait 80% de ma config de workflow. Je crois qu’il est temps de découper en plusieurs configs. Après ça et de nombreux pushs d’essai sur GitHub, ça finit par être tout vert. Au passage, c’est pas facile de déboguer un pipeline, notamment par exemple quand t’as un Gradle qui fait n’importe quoi. Heureusement que ça supporte le meilleur débogueur du monde : printf.
Au final je n’ai qu’un petit bémol à mettre sur GitHub Actions, c’est qu’ils fournissent des images déjà bien remplies par défaut. Des compilateurs, des tonnes d’outils et de libs… Ce n’est pas trop ce que j’attends de ma CI. Pour moi le but de la CI est de vérifier toute la chaîne du build, depuis la récupération des dépendances jusqu’à la publication. Forcément, si les dépendances sont pré-installées, on risque de passer à côté d’un problème de configuration du projet, et quand on le met sur un autre système, paf ! ça ne marche pas. Pour éviter cela j’ai simplement utilisé la possibilité offerte par GitHub de lancer les jobs dans des conteneurs Docker avec une image donnée. Ainsi je peux vérifier que tout fonctionne sur un système minimal, depuis l’installation des dépendances jusqu’aux tests.
Gestion des dépendances
Ah bah tiens, puisqu’on parle de dépendances…
S’il y a bien un sujet tendance autour du C++ c’est la gestion des dépendances. Rust a Cargo, Node.js a NPM, Python a pip, et nous, qu’est-ce qu’on a ? Nous aussi on aimerait bien télécharger Internet à chaque build, tirer des dépendances vérolées, et casser la prod par la suppression d’un paquet tiers.
Côté C++ on a Conan, vcpkg, CPM.cmake, et sûrement d’autres. De vcpkg je ne sais pas grand chose. Conan me fait de l’œil depuis un moment mais je suis content de ne pas m’y être mis car avec le récent passage à la version 2 ses utilisateurs ont semble-t-il pas mal souffert. Quant à CPM.cmake, le fait que ce soit intégré dans CMake me plaît bien mais Conan s’y intègre très bien aussi. Comme je n’ai pas trop creusé le sujet, je compte sur toi pour m’éduquer en commentaire.
Côté CPM.cmake il y a en tout cas une limitation bloquante de mon point de vue : ça ne cache pas les dépendances. C’est à dire que si tu as plusieurs dossiers de builds, par exemple debug, release, sanitizers, alors chaque dossier va générer le téléchargement et la compilation de chaque dépendance. Quel gâchis de ressources :(
En général on va distinguer les dépendances de l’app et de l’environnement de build. Histoire de parler d’un cas concret, prenons par exemple mon beau projet, au hasard. Dans ce que je considère comme dépendances on aura :
- Les dépendances de l’application (i.e. nécessaire à l’exécution de l’app) : Axmol, iscool::core, fmt, EnTT, Boost, GoogleTest. Celles-ci viennent avec leurs propres dépendances : moFileReader, JsonCpp, plein d’autres.
- Les dépendances de l’environnement de build, que j’utilise lors du build mais pas dans le programme (Pack my Sprites, jsonlint) ou que j’utilise pour les tests mais pas pour le build (clang-format, ShellCheck, yamllint). Et bien sûr il y a aussi quelques outils indispensables tels que le compilateur, évidemment, ou encore ccache, curl, file, CMake, ninja, Python.
Comme je fais une version Android il me faut aussi le SDK avec certains packages (les build tools, le NDK) mais surtout, attention, certaines des libs ci-dessus sont utilisées différemment selon que la cible soit Android ou Linux. Par exemple, j’ai besoin de Boost.Program_options pour le build Linux mais pas pour Android.
Re-attention, contrainte supplémentaire, pour certaines dépendances je veux un build debug quand je compile mon application en debug. De plus, il m’arrive de patcher certaines dépendances, par exemple Axmol, et je veux alors pouvoir utiliser une branche de mon dépôt local pour fournir la dépendance de mon app. Est-ce qu’il existe un gestionnaire de paquets qui pourrait me gérer tout ça ?
Dans un cadre professionnel je passerais sûrement par une image Docker pour poser l’environnement de build, mais là je fais ça en loisir alors je vais éviter. Pour moi le gros défaut de Docker, juste avant l’énorme consommation de ressources réseau et disque, c’est le manque d’orthogonalité. Par exemple si je mets dans mon Dockerfile l’installation du SDK Android puis de Pack my Sprites, alors il considère que ce dernier dépend du premier. Alors qu’ils sont totalement indépendants ! Du coup quand je change le SDK ça re-déroule les étapes qui suivent. C’est long et trop gourmand. Quant à utiliser Docker pour les dépendances de l’app, avec tous les patchs que j’y fais, c’est peine perdue ; je passerais mon temps à reconstruire l’image.
En attendant d’avoir l’outil de mes rêves j’ai bricolé un système de gestion de dépendances qui va faire des archives selon le type de build, depuis un dépôt local ou distant, et les réutiliser d’un build à l’autre. Le déploiement se fait dans un dossier local au dépôt, en distinguant l’hôte de la cible, comme ça je ne pourri pas mon système. En plus je choisis ce qu’il y a dans les archives et elles sont installables et supprimables indépendamment. Alors c’est une solution un peu bancale, qui gère mal la transitivité, mais ça me semble être la solution qui me fait le moins perdre de temps à l’usage. Et puis c’est du loisir, j’ai le droit.
Le jeu
« Trois heures que je lis sa prose et il n’a toujours pas parlé du jeu ». Eh oh, je t’entends hein !
Alors oui, effectivement, je ne parle pas du jeu. Je ne mets même pas de lien et il faudra se contenter des captures. Comme tu l’as vu, il n’y a pas grand chose à montrer. Pas grand chose à montrer pour l’instant, mais crois bien que dès que j’aurais plus d’information tu en sera le premier informé.
Bonus
Si toi aussi tu en as marre des sites qui s’approprient tes raccourcis claviers, par exemple docker.com qui met le focus dans son champ de recherche sur un Ctrl+K, tu peux aller dans about:config
et mettre la valeur 2 dans permissions.default.shortcuts
. Ainsi Firefox réagira de nouveau à ces raccourcis claviers. De rien.