Cet article est publié sous licence CC BY-NC-SA
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
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
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
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.
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
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.
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 !
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.
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
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.
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
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.
Nos conseils et ressources pour vos développements produit.