Article écrit par Hugo Fabre
Contrôler la boucle principale de Gosu
L’idée de cet article est d’expliquer pourquoi on peut vouloir prendre le contrôle sur la boucle principale de notre moteur de jeu et comment on peut y arriver avec Gosu. Avant tout il faut expliquer comment fonctionne un jeu vidéo.
La boucle principale
La boucle principale (ou main loop) est au cœur d’un jeu vidéo (ou de son moteur). L’idée est simple :
state = init_game while game_running? inputs = poll_inputs state = update(inputs, state) draw(state) end
On peut retrouver une boucle de ce genre (bien sûr ici elle est simplifiée à l’extrême) dans tous les jeux. L’idée est très claire, tant que l’on joue, on récupère les entrées du joueur (appui sur une touche ou encore mouvement de souris), on les utilise pour mettre à jour l’état (position du joueur par exemple) de notre jeu puis on le dessine. Maintenant regardons de plus près comment on retrouve ce concept dans Gosu.
Dans Gosu
Avant tout regardons le hello world de Gosu :
require 'gosu' class GameWindow < Gosu::Window def initialize super(640, 480) end def update # Ici on met à jour notre état en utilisant la logique de notre jeu end def draw # Ici on dessine notre état end end GameWindow.new.show
On retrouve bien la plupart des concepts vus précédemment mais pas de boucle. Si on regarde le code attentivement on remarque qu’on fait appel une méthode show
sur notre fenêtre de jeu que l’on n’a jamais définis. Celle-ci doit se trouver dans la classe dont on hérite : Gosu::Window
. Pour aller plus loin il faudra donc aller voir dans la documentation ou bien le code source de Gosu.
Mais pourquoi se prendre la tête avec tout ça alors que Gosu nous fournit une API simple à utiliser ?
Pourquoi prendre le contrôle de sa boucle principale ?
Si seule la solution vous intéresse, cette partie n’est pas obligatoire pour la compréhension, vous pouvez passer à la suite.
Je vais prendre l’exemple qui m’a fait me poser cette question, mais il y a très probablement d’autres situations. Je voulais explorer le domaine du réseau dans le jeu vidéo, pour me simplifier la tâche j’ai décidé d’utiliser des outils que je connais et que je maitrise déjà : Ruby et Gosu. Seulement j’ai rencontré une problématique.
Le réseau dans le jeu vidéo
Je vais proposer ici une explication très courte, car ce n’est pas le sujet de l’article et il mériterait un article ou même une suite d’articles à lui tout seul. Il y a plusieurs manières de développer un jeu en réseau, celle qui m’intéresse ici, c’est la technique du client et du serveur avec un serveur autoritaire.
L’idée derrière cette technique, c’est que lorsqu’un client (joueur) voudra faire une action il enverra un message au serveur qui simulera l’action et dira au client si oui ou non elle est valide avant de la transmettre aux autres joueurs. Grâce à cette technique ou pourra éviter la triche, car si un joueur peut modifier son client pour tricher il ne pourra pas toucher à notre serveur qui lui est distant.
Pour pouvoir valider les entrées des clients, le serveur doit donc faire tourner le jeu lui-même et c’est lui qui aura l’état du jeu de référence. Il le mettra à jour lorsqu’un client enverra un message. Seulement côté client on ne peut pas attendre que le message arrive jusqu’au serveur et qu’un OK nous revienne, sinon le jeu ne serait pas très réactif ; on veut donc pouvoir faire tourner la simulation côté client aussi.
Partage du code
En simulant le jeu côté serveur et client, on se dit qu’il serait dommage de dupliquer la logique du jeu à deux endroits alors qu’on pourrait partager le code commun. Seulement il serait quand même dommage d’embarquer sur notre serveur la bibliothèque d’affichage alors qu’on n’a aucun besoin d’afficher l’état de notre jeu sur un serveur qui n’aura probablement pas d’écran (à part pour le déboguer en local). De plus pour éviter au maximum les désynchronisations entre le client et le serveur (latence, perte de paquet…) on voudra avoir la même boucle principale des deux côtés. Sauf que comme nous l’avons vu plus haut, Gosu nous cache sa boucle principale, il va donc falloir trouver une solution pour intégrer la nôtre à la place de la sienne.
Prendre le contrôle
Reprenons le hello world de Gosu :
require 'gosu' class GameWindow < Gosu::Window def initialize super(640, 480) end def update # Ici on met à jour notre état en utilisant la logique de notre jeu end def draw # Ici on dessine notre état end end GameWindow.new.show
Donc comme vu plus haut il va falloir aller voir ce que fait cette méthode Gosu::Window#show
, rendez-vous donc à la définition de celle-ci. On remarque qu’entre autres choses plus compliquées celle-ci boucle (c’est notre main loop) simplement sur une méthode tick
// Ligne 264 while (tick()) { // ... }
Allons regarder de plus près cette méthode. Ici, on peut retrouver tous les concepts sur la main loop vus en début d’article (récupération des inputs, mise à jour de l’état et affichage) :
bool Gosu::Window::tick() { // ... // Ligne 306 SDL_Event e; while (SDL_PollEvent(&e)) { // On récupère les entrées du joueur via la SDL } // ... // Ligne 348 // Cette méthode `update` est tout simplement celle que l'on définit dans notre propre classe `GameWindow` update(); // ... // Ligne 355 // Idem ici, c'est notre méthode `draw` draw(); // ... }
Évidemment le fonctionnement est plus complexe que celui-ci, mais c’est en réalité tout ce qu’il nous faut voir et comprendre. Du coup notre solution est toute simple, on va remplacer la méthode show
qui s’occupe de la boucle par notre propre boucle :
require 'gosu' class GameWindow < Gosu::Window def initialize super(640, 480) @font = Gosu::Font.new(self, Gosu::default_font_name, 15) @frame = 0 end def run while running? tick end close end def update # Ici on met à jour notre état en utilisant la logique de notre jeu @frame += 1 end def draw # Ici on dessine notre état @font.draw_text("Frame number #{@frame}", 10, 30, 1, 1, 1) end private def running? @frame < 1000 end end GameWindow.new.run
Pour l’exemple j’ai rajouté de la logique à notre fenêtre, on compte le nombre de frame pour l’afficher et on quitte après les 1000 premières. Pour éviter de trop faire chauffer votre machine pensez à limiter le nombre de tours de boucle par seconde (les FPS du jeu) :
while self.running? tick sleep(1 / 60.0) end
Ici on le limite à environ 60 frame par seconde. Encore une fois, c’est un gros raccourcis pour une problématique complexe, si vous voulez aller plus loin je vous invite à lire cet article sur le sujet (en anglais, mais très complet).
Bon c’est un bon début, mais ici notre logique est encore très couplée à notre fenêtre. Il est temps de changer ça et vous allez voir que c’est très simple, le gros du travail a déjà été fait :
require 'gosu' class GosuRenderer def initialize @window = GameWindow.new end def draw(state) @window.state = state @window.tick # On fait appel à la méthode `tick` de Gosu::Window end def terminate @window.close end end class GameWindow < Gosu::Window attr_writer :state def initialize super(640, 480) @font = Gosu::Font.new(self, Gosu::default_font_name, 15) @state = {} end def update # Notre état n'est plus géré par notre fenêtre, mais par notre moteur, # donc il ne se passe rien ici end def draw # Ici on dessine notre état @font.draw_text("Frame number #{@state[:frame]}", 10, 30, 1, 1, 1) if @state[:frame] end end class VoidRenderer # Implémente "l'interface" de nos moteurs de rendu def draw(_state); end def terminate; end end class Engine def initialize(renderer: VoidRenderer.new) @renderer = renderer @frame = 0 end def run while running? # On affiche notre état dans la console pour vérifier que # tout fonctionne même sans affichage. puts @frame tick sleep(1 / 60.0) end @renderer.terminate end private def tick @frame += 1 @renderer.draw(frame: @frame) end def running? @frame < 1000 end end Engine.new(renderer: GosuRenderer.new).run
Et voilà notre logique est complètement découplée de notre affichage. Vous pouvez faire le teste par vous-même en commentant tout le code au-dessus de la classe VoidRenderer
et en appelant :
Engine.new(renderer: VoidRenderer.new).run
Et notre logique s’exécutera quand même, on peut le vérifier grâce au puts
.