Évaluons votre projet

Gosu et sa boucle principale

Publié le 1 avril 2021 par Hugo Fabre | ruby

Cet article est publié sous licence CC BY-NC-SA

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.


L’équipe Synbioz.
Libres d’être ensemble.