Introduction à DragonRuby

Publié le 22 janvier 2021 par Hugo Fabre | ruby - framework

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

DragonRuby Game Toolkit

DragonRuby est un moteur de jeu 2D développé par Ryan C. Gordon, l’un des principaux développeurs de la SDL et Amir Rajan un développeur de jeu vidéo reconnu pour avoir produit plusieurs jeux indépendants à succès sur plusieurs plateformes (iOS, Android et Nintendo Switch) le tout grâce notamment à DragonRuby. Les promesses de ce moteur de jeu sont nombreuses, on y retrouve la facilité de prise en main, la compilation cross-platform, les performances et la légèreté.

Le produit en lui-même est payant et son code source n’est en majorité pas accessible au public. Il est cependant possible d’avoir une licence gratuite sous certaines conditions. De plus on peut voir certaine partie du code source libre sur le GitHub dédié.

Pour plus de détails concernant le code source et les licences, je vous invite à lire la FAQ. Globalement ce qu’il faut retenir c’est que le moteur de jeu nous met à disposition une API très simple soutenue par la SDL et mruby. J’ai donc voulu tester, c’est quand même pas tous les jours qu’on croise un moteur de jeu qui permet d’utiliser Ruby et de compiler pour toutes les plateformes desktops en une seule commande.

Avant de commencer, pour les gens qui sont pressés, vous pouvez retrouver le code source et les assets sur le dépôt git dédié. Voici un aperçu du résultat final :

Les assets sont produits par Kenney

Hello World

On ne peut pas y échapper, c’est le classique, on ouvre la documentation et voilà le Hello World avec DragonRuby :

# main.rb

def tick(args)
  args.outputs.labels << [580, 400, 'Hello World!']
end

Quelques petites remarques pour mieux comprendre la structure du moteur de jeu. DragonRuby tourne toujours (dans la limite des possibilités de votre ordinateur) à 60 IPS et va donc appeler cette fonction tick 60 fois par seconde en lui passant args.

Ce paramètre args est au cœur du fonctionnement du moteur. C’est à partir de cet objet qu’on récupérera les différentes entrées de notre jeu, qu’on modifiera l’état et qu’on enregistrera les formes à afficher à l’écran via :

args.inputs
args.state
args.outputs

Pour les différentes possibilités offertes par inputs et ouputs je vous invite à aller voir leurs documentations respectives, nous en utiliserons une petite partie au cours de cet article. Pour state, c’est un peu différent. C’est un objet « ouvert » : vous pouvez y mettre tout ce que vous voulez, ça sera persisté et transmis à chaque frame (appel à la fonction tick).

Il y a plusieurs choses qui démarquent DragonRuby des autres moteurs de jeux, mais il y en a surtout une qui peut vous perturber si vous venez d’autre chose comme Gosu, Raylib ou Monogame. L’axe Y va du bas vers le haut, le point (0, 0) est donc en bas à gauche de notre fenêtre :

(+y)
^
|
|
|
|
|
0-------------> (+x)

Une dernière information avant d’attaquer. Nous n’avons pas vraiment abordé le pourquoi DragonRuby se vend comme simple à prendre en main. En fait, la réponse est simple : vous pouvez tout faire avec le moteur de jeu en manipulant une seule primitive, les tableaux. Ce type étant disponible dans presque de tous les langages de programmation, même si vous ne connaissez pas Ruby vous pourrez vous y retrouver assez facilement. On peut cependant utiliser plus ou moins ce qu’on veut grâce au duck typing. Pour clarifier un peu les choses voilà deux solutions fournies par la documentation pour afficher une image :

# Via un tableau. À savoir qu'on peut passer plus de paramètres au besoin (pour gérer l'échelle, la rotation…)
def tick(args)
  # x, y, width, height, path_to_sprite
  args.outputs.sprites << [100, 100, 32, 64, "sprites/player.png"]
end
# Via le duck typing

# On définit une classe qui elle-même définit toutes les méthodes dont DragonRuby aura besoin pour afficher un _sprite_
class Sprite
  attr_accessor :x, :y, :w, :h, :path, :angle, :a, :r, :g, :b, :tile_x,
                :tile_y, :tile_w, :tile_h, :flip_horizontally,
                :flip_vertically, :angle_anchor_x, :angle_anchor_y

  # Cette méthode est obligatoire pour que DragonRuby puisse reconnaitre ce qu'il doit afficher
  def primitive_marker
    :sprite
  end
end

# Une classe pour faciliter la création d'un sprite donné.
class PlayerSprite < Sprite
  def initialize(x, y, w, h)
    self.x = x
    self.y = y
    self.w = w
    self.h = h
    self.path = 'sprites/player.png'
  end
end

# Et enfin on peut afficher le sprite
def tick(args)
  args.outputs.sprites << PlayerSprite.new(10, 10, 32, 64)
end

L’avantage de travailler quasiment uniquement avec des types simples, c’est que chaque développeur est libre de faire comme il le veut. Pour ma part j’ai opté pour une solution hybride qui me semble simple d’utilisation et clair à la lecture :

class Player
  def initialize(x, y, w, h)
    @x = x
    @y = y
    @w = w
    @h = h
  end

  def to_sprite
    [@x, @y, @w, @h, 'sprites/player.png']
  end
end

def tick(args)
  args.outputs.sprites << Player.new(10, 10, 32, 64).to_sprite
end

Démonstration

Je vous propose de réaliser un jeu très simple. Vous incarnez un gros carré bleu, qui peut se déplacer dans les quatre directions. Dans notre fenêtre il y aura deux types entités en plus du joueur qui apparaitront aléatoirement : des petits carrés jaunes (des étoiles si vous avez un peu d’imagination) et des petits carrés rouges (des bombes si vous avez beaucoup d’imagination). Chaque fois que vous ramasserez une étoile, vous gagnez 10 points et à chaque fois que vous marchez sur une bombe vous perdez une vie dans une limite de trois.

Bon, on est bien d’accord c’est pas aujourd’hui que vous allez faire concurrence au dernier jeu à la mode, mais au moins vous vous n’aurez pas à repousser plusieurs fois la sortie de votre jeu, ni à faire cruncher vos équipes pendant plusieurs mois (et ça, c’est bien !)

La structure du jeu, lorsque vous aurez ouvert votre archive DragonRuby, à la racine vous aurez un exécutable DragonRuby qui servira à lancer le jeu. Vous y trouverez aussi un dossier sample dans lequel je vous conseille fortement de jeter un coup d’œil. Vous pourrez y trouver plein de jeux ou d’exemples déjà faits, qui sont souvent là pour expliciter ou faire une démonstration d’une fonctionnalité spécifique du moteur. Pour cette fois, ce qui nous intéresse c’est plutôt le dossier mygame, voilà sa structure :

./mygame
├── app
├── data
├── fonts
├── metadata
│   ├── game_metadata.txt
│   └── icon.png
├── sounds
└── sprites

Pour vous aider à vous y repérer, voilà ce qu’on aura à la fin de l’article :

./mygame
├── app
│   ├── body.rb
│   ├── bomb.rb
│   ├── entity.rb
│   ├── game.rb
│   ├── main.rb
│   ├── player.rb
│   └── star.rb
├── data
├── fonts
├── metadata
│   ├── game_metadata.txt
│   └── icon.png
├── sounds
└── sprites
    ├── bomb.png
    ├── player.png
    └── star.png

Préparation

On aura besoin d’une classe Game qui pourra gérer notre jeu. Dans notre cas il n’y a pas grand-chose à faire, mais l’avantage c’est que cette structure est facilement évolutive pour s’intégrer avec un système de scènes par exemple.

# game.rb

# On garde ça pour plus tard
# require "app/body.rb"
# require "app/entity.rb"
# require "app/player.rb"
# require "app/bomb.rb"
# require "app/star.rb"

class Game
  def tick(args)
  # Hmm pour le moment on ne fait rien
  end
end

Puis dans le fichier principal :

# main.rb
require "app/game.rb"

def tick(args)
  args.state.game ||= Game.new
  args.state.game.tick(args)
end

Et pour lancer notre jeu, il suffit d’exécuter la commande ./dragonruby

Pour mutualiser certaines choses, nous allons créer deux sous-classes qui serviront de base à nos entités (Le joueur, les étoiles et les bombes).

# body.rb
class Body
  attr_reader :x, :y, :w, :h, :type

  def initialize(x, y, w, h, type)
    @x = x
    @y = y
    @w = w
    @h = h
    @type = type
  end

  def rect
    [@x, @y, @w, @h]
  end
end

Rien de bien compliqué ici. Notre Body aura une position (x et y), une hauteur (w), une largeur (h), et un type dont nous aurons besoin pour savoir quoi faire en cas de collision. On peut passer à la classe qui va définir une entité :

# entity.rb
class Entity < Body
  def initialize(x, y, w, h, type)
    super(x, y, w, h, type)
    @speed = 7
  end

  def move(direction)
    case direction
    when :up
      @y += @speed
    when :down
      @y -= @speed
    when :right
      @x += @speed
    else
      @x -= @speed
    end
  end
end

Encore une fois, très simple. On ajoute une méthode move qui permettra de faire bouger une entité et on définit une vitesse de mouvement.

Le joueur

Nous pouvons ensuite nous attaquer au joueur :

# player.rb
class Player < Entity
  WIDTH = 32
  HEIGHT = 64

  attr_reader :score, :hp

  def initialize(x, y)
    super(x, y, WIDTH, HEIGHT, :player)
    @hp = 3
    @score = 0
    @score_by_star = 10
  end

  def to_sprite
    self.rect << 'sprites/player.png'
  end

  def score_to_s
    "Score: #{@score}"
  end

  def hp_to_s
    "Hp: #{@hp}"
  end
end

L’affichage

Et pour finir on doit afficher tout ça, on modifie donc notre classe Game:

# game.rb

require "app/body.rb"
require "app/entity.rb"
require "app/player.rb"

# On garde ça pour plus tard
# require "app/bomb.rb"
# require "app/star.rb"

class Game
  def initialize
    @state_set_up = false
  end

  def tick(args)
    init_state(args) unless @state_set_up
    draw(args)
  end

  private

  def init_state(args)
    args.state.player = Player.new(620, 0)
    @state_set_up = true
  end

  def draw(args)
    args.outputs.labels << [20, 700, args.state.player.score_to_s]
    args.outputs.labels << [20, 680, args.state.player.hp_to_s]
    args.outputs.sprites << args.state.player.to_sprite
  end
end

À cette étape si vous lancez le jeu ./dragonruby, vous devriez avoir un joueur sur un fond blanc.

un vaisseau spacial bleu est positionné en bas au centre de l'écran

Les inputs

Nous allons ensuite capturer les entrées clavier du joueur pour déplacer notre personnage, en DragonRuby c’est très simple on suit simplement la documentation.

On rajoute une petite méthode privée à notre classe Game qui va s’occuper de tout ça et on pense à l’appeler :

# game.rb

class Game
  INPUT_MAP = {
    up: :z,
    left: :q,
    right: :d,
    down: :s
  }

  # ...

  def tick(args)
    init_state(args) unless @state_set_up
    handle_input(args)
    draw(args)
  end

  private

  # ...

  def handle_input(args)
    INPUT_MAP.slice(:left, :right, :up, :down).each do |key, input|
      args.state.player.move(key) if args.inputs.keyboard.key_held.send(input)
    end
  end
end

J’aime bien utiliser un dictionnaire pour configurer mes touches, ça permet d’en changer facilement et éventuellement de déléguer ce travail à un fichier de configuration qu’on pourrait lire au démarrage du jeu.

La magie opère et vous devriez avoir un personnage qui bouge !

un vaisseau spacial bleu se déplace sur l'écran

Un objectif

Eh oui, pour un jeu il est quand même plus sympa d’avoir un objectif, sinon on ne sait pas trop quoi faire. Pour nous ça sera de ramasser les étoiles.

Les étoiles

On aura donc besoin d’un nouveau type d’entité :

# star.rb

class Star < Entity
  def initialize(x, y)
    super(x, y, 15, 15, :star)
  end

  def to_sprite
    self.rect << 'sprites/star.png'
  end
end

On va ensuite rajouter des étoiles de manière aléatoire dans la fenêtre. Pour ça nous allons modifier notre classe Game pour qu’elle puisse exécuter notre logique à chaque frame. Nous allons toucher à pas mal de briques de notre classe Game. Pour faciliter la lecture je la remets entière avec des commentaires qui expliquent les changements.

# game.rb

# game.rb

require "app/body.rb"
require "app/entity.rb"
require "app/player.rb"
require "app/star.rb"

# On garde ça pour plus tard
# require "app/bomb.rb"

class Game
  # Deux constantes pour connaitre la taille de la fenêtre (fixe en DragonRuby)
  WINDOW_WIDTH = 1280
  WINDOW_HEIGHT = 720
  INPUT_MAP = {
    up: :z,
    left: :q,
    right: :d,
    down: :s
  }

  def initialize
    @state_set_up = false
  end

  def tick(args)
    init_state(args) unless @state_set_up
    process_logic(args)
    draw(args)
  end

  private

  def init_state(args)
    args.state.player = Player.new(620, 0)
    # On rajoute ici un tableau pour garder en mémoire les étoiles à afficher entre chaque `tick`
    args.state.stars = []
    @state_set_up = true
  end

  def process_logic(args)
    # Une méthode générique dont on se resservira plus tard avec l'ajout des bombes.
    spawn_entites(args)
    handle_input(args)

    # On va définir cette méthode juste après, c'est celle qui nous servira à savoir si on a ramassé une étoile
    args.state.player.update!(args.state)
  end

  def spawn_entites(args)
    # Si il n'y pas plus de 10 étoiles et de manière aléatoire, on rajoute une étoile.
    spawn_star(args) if args.state.stars.count < 10 && rand(75) == 1
  end

  # Nouvelle méthode pour faire apparaitre une étoile
  def spawn_star(args)
    pos = random_pos
    args.state.stars << Star.new(pos[:x], pos[:y])
  end

  def random_pos
    # On évite de faire apparaitre une étoile hors de la fenêtre.
    x = rand(WINDOW_WIDTH - 15)
    y = rand(WINDOW_HEIGHT - 15)

    { x: x, y: y }
  end

  def draw(args)
    args.outputs.labels << [20, 700, args.state.player.score_to_s]
    args.outputs.labels << [20, 680, args.state.player.hp_to_s]
    args.outputs.sprites << args.state.player.to_sprite

    # Et on pense bien sur à afficher nos étoiles !
    args.state.stars.each do |star|
      args.outputs.sprites << star.to_sprite
    end
  end

  def handle_input(args)
    INPUT_MAP.slice(:left, :right, :up, :down).each do |key, input|
      args.state.player.move(key) if args.inputs.keyboard.key_held.send(input)
    end
  end
end

Et le ramassage

Si vous avez bien lu les commentaires du code ci-dessus, vous verrez qu’on appelle une méthode qui n’est pas encore définie sur notre Player.

# player.rb

class Player
  # ...

  def update!(state)
    state.stars.each do |star|
      if self.rect.intersect_rect?(star.rect)
        @score += @score_by_star
        state.stars.delete(star)
      end
    end
  end

  # ...
end

Vous remarquerez qu’on utilise une méthode Array#intersect_rect?(ary) que vous n’aviez probablement jamais vue. En effet, celle-ci est proposée par DragonRuby. Elle permet simplement de savoir si deux rectangles se touchent (on parle donc de collision). Vous pouvez retrouver plus d’information en lisant la documentation.

Vous devriez maintenant avoir un personnage qui bouge, et dont le score augmente lorsqu’il rentre en contact avec des étoiles.

un vaisseau spacial bleu collecte des étoiles

La mort

Eh oui, tout comme les objectifs, si on n’a aucune condition de défaite dans un jeu, on tourne vite en rond. Bon dans notre cas ça ne sera pas bien compliqué, on va suivre exactement la même logique que pour les étoiles, sauf qu’en cas de collision, on perdra une vie au lieu de gagner du score.

# bomb.rb

class Bomb < Entity
  def initialize(x, y)
    super(x, y, 15, 15, :bomb)
  end

  def to_sprite
    self.rect << 'sprites/bomb.png'
  end
end
# game.rb

# ...
require "app/bomb.rb"
# ...

class Game
  # ...

  private

  # ...

  def init_state(args)
    args.state.player = Player.new(620, 0)
    args.state.stars = []
    # On rajoute ici un tableau pour garder en mémoire les bombes à afficher entre chaque `tick`
    args.state.bombs = []
    @state_set_up = true
  end

  def draw(args)
    # ...
    # Et on pense bien sûr à afficher nos bombes !
    args.state.bombs.each do |bomb|
      args.outputs.sprites << bomb.to_sprite
    end
  end

  def spawn_entites(args)
    spawn_star(args) if args.state.stars.count < 10 && rand(75) == 1
    # On va aussi faire apparaitre des bombes, mais moins souvent que des étoiles.
    spawn_bomb(args) if rand(125) == 1
  end

  # Nouvelle méthode pour faire apparaitre une bombe,
  # Quasiment la même que celle qu'on utilise pour les étoiles.
  def spawn_bomb(args)
    pos = random_pos
    args.state.bombs << Bomb.new(pos[:x], pos[:y])
  end
end

Et pour finir, gérer la collision avec les bombes, comme pour les étoiles :

# player.rb

class Player

  # ...

  def update!(state)
    state.stars.each do |star|
      if self.rect.intersect_rect?(star.rect)
        @score += @score_by_star
        state.stars.delete(star)
      end
    end

    state.bombs.each do |bomb|
      if self.rect.intersect_rect?(bomb.rect)
        @hp -= 1
        state.bombs.delete(bomb)
      end
    end
  end

  # ...
end

Si vous suivez bien, il manque un dernier détail. Il faut quitter le jeu lorsqu’on meurt. On va donc modifier une dernière fois notre classe Game

# game.rb

class Game
  # ...

  def tick(args)
    # ...

    # $gtk est l'objet global du moteur de jeu mis à disposition.
    $gtk.exit if args.state.player.hp < 0
  end

  # ...
end

un vaisseau spacial bleu collecte des étoiles en évitant les astéroïdes

Pour conclure

Globalement je voudrais saluer le travail qui a été fait avec ce moteur de jeu, Les promesses mises en avant sont tenues de mon point de vue, j’ai vraiment été bluffé par la cross-compilation qui fonctionne out of the box. Pour utiliser Ruby en plus de ça c’est vraiment sympa. De plus, malgré la documentation un peu éparse il y a des tonnes d’exemples qui sont fournis avec l’outil et enfin un discord où les gens sont disponibles pour vous guider.

J’ai quand même noté quelques points négatifs, il y a par exemple le fait d’avoir inversé l’axe des y par rapport à la grande majorité des moteurs de jeu du même gabarit (je parle ici plutôt de Monogame, ou bien la Raylib plutôt que Unity). Et surtout le fait d’être dans un environnement assez fermé que j’ai trouvé très difficile à déboguer (pas de puts, pas de débogueur).

Je vous invite à essayer par vous-même et à nous donner votre avis. Pour rappel même si DragonRuby est payant, il est souvent possible d’en récupérer des licences gratuites dans le cadre de certaines gamejam ou si vous rentrez dans les critères définis par Amir.


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