Crepes-party-hard-yolo-swag 2015
Durée de lecture estimée : 19 minutes
Le voilà enfin ! Mon premier billet de postmortem !
Ici, je vais tenter de vous expliquer la création du jeu Crepes-party-hard-yolo-swag 2015 (appelé cphys2015 dans la suite du texte), dès les premiers prototypes jusqu’à la publication du produit fini.
Vous pouvez aller jeter un œil à la page de projet si vous ne connaissez pas encore le jeu.
Je ne connais pas beaucoup de billets concernant le développement de jeux dans des langages fonctionnels, j’espère que cet article comblera un peu ce vide.
Vous pouvez suivre le texte et essayer le jeu aux différentes étapes de son développement en clonant le dépôt git,
disponible à cette adresse.
Les révisions en question seront annotées dans les paragraphes.
Utilisez git checkout [revision]
pour obtenir le jeu dans l’état désiré.
Les révisions sont indiqués en gras tout au long du texte.
Pour lancer le jeu, suivez les instructions disponibles dans le fichier README du dépôt du jeu
et lancez la commande suivante: make cairo-utils.so && csi -s crepe.scm
Tapez Ctrl-C dans le terminal dans lequel vous avez lancé le jeu pour le fermer.
Un peu de contexte
Comme dit dans la page de projet, ce jeu a été fait à l’occasion de mon premier hébergement de stagiaire, son but était d’apprendre la conception et le développement de jeux-vidéo.
Son stage a duré deux mois. Durant les deux premières semaines, il a principalement étudié le langage de programmation Scheme. Son expérience de programmation étant principalement orientée vers le langage C, les approches fonctionnelle et récursive lui étaient un peu étrangères.
Je lui ai enseigné les bases du langage, et lui ai fait lire le livre The Little Schemer, avec lequel il s’est beaucoup exercé.
Juste après ça, nous avons directement entamé le gros du sujet : la conception d’un jeu.
Brainstorming
Après quelques jours de réflexion, nous nous sommes mis d’accord sur l’idée de créer un jeu par mois, ce que nous avons réussi en faisant cphys2015 et en démarrant un deuxième projet.
Le véritable but était de publier un jeu bien fini, même si celui-ci est très simple. C’est pour ça que nous nous sommes lancés sur une idée aux saveurs de Game&Watch : rattraper des crêpes.
Comme il s’agissait de la première expérience de programmation fonctionnelle de mon stagiaire, je me suis résigné à n’utiliser que des concepts basiques de ce paradigme, comme les fonctions de premier-ordre, la récursion ou encore la programmation sans effet de bord, pour lesquels il avait déjà un peu de mal. C’est pour cela qu’il n’y aucun concept de très haut niveau par lesquels je suis habituellement intéressés, comme la programmation fonctionnelle réactive, dans ce jeu.
Rétrospectivement, nous aurions probablement dû utiliser ces concepts. Certaines parties du code sont maladroites.
Phase de prototypage
Le premier prototype était très proche du jeu Game&Watch Oil Panic, même si nous ne cherchions pas cela explicitement.
C’était un jeu de blocs tombants très simple, où l’on déplace le personnage avec les touches fléchées pour rattraper les dits blocs (voir la révision v1.0~82 dans le dépôt git).
Pour gérer les graphismes, j’ai utilisé une de mes petites bibliothèques nommée cairo-utils, cette bibliothèque est un plagiat sans honte des fonctions de dessin vectoriel de l’egg doodle.
Nous avons ensuite essayé une variante que Reptifur (notre artiste) nous a suggéré : renvoyer les crêpes au plafond.
Après quelques révisions nous avons implémenté cette idée. Très grossièrement dans un premier temps (v1.0~81) puis avec vitesse et temps de décollement aléatoire (v1.0~79).
Nous sommes restés sur cette idée pour le jeu final, car elle était déjà plutôt amusante.
À ce point du projet, les crêpes étaient représentées comme une liste de leur position ligne-colonne, leur vitesse, ainsi que le dernier moment où la crêpe a bougé, afin de calculer le prochain moment de descente.
Pour mettre à jour l’état du jeu, le code demande simplement quel heure il est avec get-ticks et pour chaque crêpe, vérifie si elle doit bouger en comparant l’heure actuelle avec l’heure de dernier mouvement et la vitesse de la crêpe en question. S’il est temps pour la crêpe de descendre, le code incrémente simplement sa coordonnée ligne. Un procédé similaire est utilisé pour les crêpes qui remontent.
Première passe d’affinage
C’est à ce moment que le polissage commence. Nous avons gardé la base de code du dernier prototype et avons implémenté les différentes améliorations par dessus.
Pour cette première passe, nous avons commencé par ajouter du tweening pour le mouvement des crêpes (v1.0~77).
Nous avons ensuite ajouté un léger mouvement ondulatoire aux crêpes descendantes pour simuler une sorte d’effet physique (v1.0~74).
Suite à ça, nous avons bidouillé plein de petits détails comme la vitesse de remontée, les paramètres de l’aléatoire et le calcul du score (v1.0~70). À ce stade, la difficulté n’est pas du tout réglée et le jeu est ennuyeux.
C’est à ce moment là que notre artiste (Reptifur) a commencé à nous fournir les premières révisions des graphismes du jeu (v1.0~68). Nous avons donc rapidement intégré ses contributions, en animant les crêpes (v1.0~66), ajoutant l’image de fond d’abord en noir et blanc (v1.0~64) puis avec des couleurs et quelques mises à jour sur les images des crêpes (v1.0~59).
Pour tester le jeu, vous devez à présent exécuter cette commande :
make cairo-utils.so && csi crepe.scm -e '(start-game)'
Voilà à quoi ça ressemblait :
Remaniement majeur
Nous sommes arrivés à un moment du développement où le code est devenu un peu difficile à maintenir. Nous avons donc décidé de retravailler comment le tout fonctionnait.
Pour cela, j’ai défini les différents états possibles des crêpes avec trois records : stick-state, ascend-state et descend-state.
J’ai totalement retiré la notion de ligne que nous avions précédemment, ainsi que la vitesse et l’horodatage, qui ont été déplacés vers les structures d’état où ils étaient pertinents. À partir de maintenant, les crêpes sont donc représentées comme une structure contenant son numéro de colonne et son état.
Dans chaque état est stocké l’horodatage du dernier changement d’état, ce qui sert à presque toutes les fonctions opérant sur les crêpes, comme par exemple le calcul de la hauteur de la crêpe ou l’animation de celle-ci.
J’ai trouvé cette façon de gérer l’état du jeu plutôt agréable, celui-ci est très petit et toutes les données annexes peuvent être retrouvées à partir de cette petite quantité d’information.
Après ça, je me suis dirigé vers quelque chose que je voulais depuis le début du projet : créer des binaires auto-contenus.
Pour ce faire, j’avais d’abord besoin d’un moyen d’intégrer toutes les ressources du jeu dans le binaire final, puis de lier statiquement toutes les bibliothèques CHICKEN utilisées.
Intégration des ressources
Pour rendre cette idée possible, j’ai implémenté une macro procédurale très simple, nommée file-blob, qui prend un nom de fichier et qui créé un blob litéral du contenu du fichier dans le code source au moment de l’expansion des macros. Ce blob est ensuite chargé par les fonctions de SDL travaillant sur les fichiers en mémoire. (v1.0~57).
Voilà la macro en question, accompagnée d’une de ses premières utilisations.
(define-syntax file-blob
(ir-macro-transformer
(lambda (form inject compare)
(let ((filename (cadr form)))
(with-input-from-file filename
(lambda ()
(string->blob (read-string))))))))
(define crepe-down-surface
(assert (load-img (file-blob "graph/down.png"))))
Liaison statique des eggs
Pour ce qui est de la liaison statique, j’ai d’abord essayé de réduire le nombre de dépendances du projet.
Au final, cairo-utils était la bibliothèque la moins utile, depuis que nous utilisions SDL pour le rendu des images. La dernière fonction de cairo était de dessiner le texte (score et vies restantes), ce que nous pourrions très bien faire avec des images également. J’ai donc retiré tous nos appels à cette bibliothèque (v1.0~56).
Il m’aura fallu un bon moment avant de comprendre comment lier statiquement les bibliothèques à mon programme. Les premiers essais (v1.0~55) étaient fonctionnels mais assez sales car j’ai dû intégrer toutes nos dépendances au dépôt du projet.
Plus tard, j’ai retiré ces additions au dépôt, mais ai quand même créé de petits forks pour ajouter la prise en charge de la compilation statique.
Finalement, la liaison statique des eggs est assez simple et directe.
Tout d’abord, il faut garder à l’esprit le fonctionnement de la forme de chargement de modules use
.
Au moment de la compilation, et si le module n’était pas défini dans le fichier source en train d’être compilé,
le compilateur va essayer de charger la bibliothèque d’import (souvent un fichier du style library.import.so
pour les eggs installés).
Ensuite, il va traduire toutes les utilisations des symboles importés vers leur symbole correspondant dans le module
(par exemple, un appel à poll-event! importé de sdl2 résultera à un appel à sdl2#poll-event! dans le fichier compilé),
ces symboles sont ensuite rendus disponible à l’exécution en chargeant l’objet partagé du module (comme library.so
par exemple).
L’astuce pour la liaison statique est d’utiliser un cas spécial de la forme use
en présence d’unités de compilation.
En effet, la forme use
ne va pas essayer de charger l’objet partagé à l’exécution
si le fichier compilé déclare utiliser une unité de compilation portant le même nom que le module à charger.
Il suffit donc de créer des fichiers .o pour les bibliothèques que vous voulez utiliser, enrober chacune d’elle dans une unité de compilation portant le même nom que le module qu’elle exporte, et demander au compilateur d’utiliser ces unités quand vous compilez votre projet.
Voici un petit exemple résumant la procédure :
;; my-module.scm
(module my-module (my-function)
(import scheme)
(define (my-function)
(display "Hello world!")
(newline)))
;; test.scm
(use my-module)
(my-function)
Et comment le compiler :
csc -c -J -unit my-module my-module.scm
csc -uses my-module test.scm my-module.o
Pour vérifier que votre exécutable ne dépend plus d’objets partagés, vous pouvez le lancer avec l’option -:d
.
Des lignes de ce style seront imprimées si c’est toujours le cas :
…
; loading ./my-module.so ...
[debug] loading compiled module `./my-module.so' (handle is 0x00000000010746a0)
…
Deuxième phase de polissage
Puisque nous avions enlevé cairo-utils, nous devions rajouter un moyen de montrer son score et sa vie au joueur. À cet effet, Reptifur nous a fourni une jolie fonte dessinée à la main et une image de cœur (v1.0~54).
Nous avons aussi ajouté un menu, qui a été écrit par mon stagiaire. (v1.0~53)
À partir de maintenant, lancez le jeu avec csi resources.scm -e '(main-loop-menu #f)'
.
C’est aussi à ce moment que nous avons ajouté les premiers jets du personnage (1.0~47).
Pendant cette phase, nous avons aussi réglé pas mal de paramètres et activé les options d’optimisation les plus violentes pour être sûr que le jeu fonctionnait avec celles-ci.
Accélération matérielle
Une autre fonctionnalité que je désirais était de profiter de l’accélération matérielle offerte par les GPU. C’est pour cela que j’ai commencé le deuxième gros changement du code : utiliser L’API Renderer de SDL.
Pour ce faire, j’ai dû avancer un peu plus mon fork de chicken-sdl2 car celui-ci ne rendait pas disponible l’API Renderer à l’époque.
Cette API nous a donné plusieurs avantages par rapport à l’ancienne méthode.
Premièrement, les procédures d’affichage sont devenues bien plus rapides grâce au GPU, malgré le fait que celles-ci n’étaient pas particulièrement bien conçues (nous n’avons par exemple pas implémenté de clipping ou de traitement par lot).
Une autre plus-value importante de cette méthode est la synchronisation verticale, chose qui a rendu le jeu bien moins gourmand en processeur.
Utiliser cette API a aussi permis de rendre le jeu indépendant de la résolution des moniteurs. Avec quelques appels le jeu choisi désormais la meilleure résolution disponible, créé une surface centrée en 16:9 (si le moniteur n’est pas 16:9), et redimensionne les graphismes pour les faire tenir. Il utilise un algorithme d’interpolation linéaire si possible.
Voilà le code qui permet d’accomplir tout ceci :
;; Use linear interpolation for scaling if available.
(SDL_SetHint "SDL_RENDER_SCALE_QUALITY" "1")
;; Create a 16:9 window as wide as possible.
(define dm (make-display-mode))
(SDL_GetDesktopDisplayMode 0 dm)
(define win (create-window! "Crepes-party-hard-yolo-swag 2015"
'undefined 'undefined
(display-mode-w dm)
(* (quotient (display-mode-w dm)
16)
9)
'(fullscreen)))
;; Use a virtual canvas of 1920x1080.
;; This will automatically scale graphics
;; and modify mouse events and drawing coordinates
(SDL_RenderSetLogicalSize renderer 1920 1080)
Prise en charge de Windows
Cibler le système d’exploitation Windows était un des gros objectifs de cet exercice de développement de jeux, je n’avais aucune expérience avec ce système avant cela. Comme nous avons choisi nos technologies pour leur portabilité, ça a été plutôt facile (CHICKEN est connu pour fonctionner sous Windows, la bibliothèque SDL est conçue pour être hautement portable…).
La plus grosse difficulté était de mettre en place un compilateur croisé, car je ne possède pas de copie de ce système. L’exercice était incroyablement indolore et très bien intégré au système de compilation de CHICKEN lui même.
J’ai simplement suivi les étapes décrites dans la section cross development du manuel, utilisant mingw-w64 comme compilateur cible.
La partie un peu difficile était de compiler l’egg chicken-sdl2, car celui-ci avait besoin de différentes options de compilation pour le système cible, ce qui a été accompli via les options -host et -target de chicken-install.
Pour être bien sûr que tout fonctionnait, j’ai simplement lancé le jeu dans l’implémentation de Windows Wine.
J’ai également envoyé le binaire à un utilisateur de Windows pour être certain que le jeu fonctionne sur le vrai système.
Polissage final
Nous y voilà, la dernière ligne droite avant la publication.
Cette phase finale de polissage est celle qui aura pris le plus longtemps.
Nous avons ajouté l’affichage du score dans le menu, sans quoi le score aurait été plutôt inutile puisque presque invisible. Nous en avons aussi profité pour mettre à jour les graphismes du menu, ainsi qu’ajouter un écran de crédits accessible par le menu. (v1.0~28)
Lancez le jeu avec csi crepe.scm -e '(menu-game-loop)'
à partir de maintenant.
La tâche majeure suivante était de nous occuper de notre personnage.
Notre artiste a donc dessiné une version coloriée de celui-ci, créé plusieurs visages et corps pour nous permettre de faire quelques animations simples qui rappellent un peu les animations sommaires des Game&Watch. (v1.0~25)
La dernière chose que nous avons faite était le réglage de nombreux petits détails. Les paramètres de l’aléatoire, le calcul du score et la courbe de difficulté étaient les grosses difficultés de cette phase, et probablement la partie la plus difficile de tout le développement, que nous avons probablement raté d’après les commentaires faits au jeu. Un bon nombre de gens trouvent le jeu trop difficile.
Pour vos oreilles
Comme nous sentions que ça manquait, nous avons ajouté des sons dans les derniers jours du développement.
Pour ce faire nous avons écrit un binding très léger pour la bibliothèque SDL2_mixer, puis nous avons ajouté des sons simples pour vérifier que le binding fonctionnait (v1.0~12).
Nous avons continué en enregistrant de sympathiques effets sonores, et nous les avons ajouté au jeu (v1.0~7).
Reptifur et moi avons aussi créé trois petits morceaux de musique, que nous avons également ajouté au jeu (v1.0).
Publication
Avec les sons et la musique en place, nous avons enfin pu publier le jeu !
J’ai donc construit des binaires auto-contenus pour Windows et Linux en utilisant la méthode décrite plus haut.
Malheureusement, je n’avais pas accès à un ordinateur sous Mac OSX et n’avais pas de compilateur croisé pour cette plate-forme non plus. Par chance Hiro, un ami à moi, a pu faire un binaire pour cette plate-forme sans trop de peine.
Retour vers le futur
Cette expérience de création de jeu-video avec CHICKEN a été vraiment plaisante.
Je n’ai eu aucun problème avec le langage ou ses outils, ce qui a rendu l’expérience très fluide.
Je vais certainement continuer à faire des jeux avec CHICKEN et très probablement essayer d’implémenter quelques abstractions et bibliothèques pour faciliter leur création avec ce langage.
J’espère que vous aurez trouvé agréable ce petit jeu et que vous apprécierez les prochains !