Coder un jeu de plateforme avec Bevy: Première partie

person
avatar (Affan) Affan
avatar (stupeflo) stupeflo
access_time
mardi 29 avril 2025 à 16h00
label
Programmation Rust Jeu Vidéo Bevy

Avec l’autorisation de Affan, je vous livre ici une traduction de ses articles sur la création de jeu de plateforme avec le moteur de jeu écrit en Rust Bevy.

Je prends cependant la liberté d’adapter ses écrits pour prendre en compte, le cas échéant, les fonctionnalités les plus récentes proposées par ce moteur de jeu dont l’API est régulièrement mise à jour; il est par ailleurs possible que j’adopte un style d’écriture qui me convienne mieux sans toutefois trahir le fond des articles originaux.

Il s’agit ici de la traduction de l’article original Learning Game Dev - Building a platformer with Bevy #1. Les deux autres articles de la série seront également traduits.

Enfin avant de poursuivre je voudrais remercier chaleureusement les quatre personnes ayant pris de leur temps pour relire ce texte.


Introduction

J’ai récemment décidé d’apprendre le développement de jeu vidéo. Je programme depuis un bon bout de temps déjà, mais je n’ai jamais eu d’expérience dans ce domaine en particulier. J’ai toujours eu envie d’en coder, ça me faisait un peu peur, en particulier, la quantité de math que ça demande, et la complexité des optimisations dans tout jeu un tant soit peu avancé. Cependant, ayant récemment découvert Rust et voulant faire quelque chose avec, j’ai décidé de me lancer.

Ceci est le premier d’une série d’articles dans laquelle j’écrirai un jeu de plateforme simple pour appréhender le sujet. Ces billets visent également les bébés rustacés1 qui seraient intéressés mais en savent peu en la matière tout comme moi. J’essayerai de fournir des explications détaillées (peut être trop!) sur les concepts généraux et le vocabulaire entourant le développement de jeu vidéo.

Le jeu

Nous allons construire un jeu à défilement horizontal très simple. L’objectif des joueureuses sera de se déplacer de plateforme en plateforme au travers d’un niveau infini et généré procéduralement à mesure que le personnage progresse vers la droite. S’il touche le sol, c’est game over. Le score sera basé sur la distance parcourue.

idée de jeu de plateforme

Vous n’avez pas idée de l’effort demandé.

C’est probablement le jeu le plus simple que l’on peut fabriquer mais c’est un bon point de départ pour les débutant·e·s.

La pile

Comme mentionné ci-dessus, nous fabriquerons ça en Rust donc certaines connaissances préliminaires dans ce langage sont nécessaires, mais pas besoin d’avoir une expertise dans ce langage pour poursuivre, car le moteur de jeu que nous allons utiliser fournit une API très developer-friendly qui ne repose par sur des fonctionnalités « complexes » de Rust.

Nous allons utiliser Bevy pour bâtir notre jeu. Bevy est un des moteurs de jeux écrits en Rust et est vraiment amusant à utiliser. Cependant, il n’est probablement pas le bon choix (pour l’instant) si vous voulez vous lancer « sérieusement » dans le développement de jeux vidéo comparé à des moteurs tels que Unity, Unreal ou Godot.

Je n’ai jamais utilisé l’un de ces moteurs auparavant mais je vais supposer que le jeu que nous allons programmer peut être rapidement réalisé au travers de ces outils-là, simplement en faisant du drag and drop des éléments à l’écran (probablement) sans avoir à écrire une seule ligne de code; avec l’interface de Godot par exemple, on peut facilement créer un jeu comme celui qu’on construit ici, en quelque mouvement de souris et un script gdscript.

Mais le but ici n’est pas tant d’avoir un produit final que de plonger dans les entrailles de la bête et de comprendre à travers le code source la base de fonctionnement des jeux vidéo en général.

ECS

Bevy est un moteur de jeu orienté données basé sur le paradigme ECS (Entité Composant Système). ECS est un paradigme de conception qui sépare votre jeu en trois parties :

Dans Bevy, les entités sont représentées simplement par leurs identifiants numériques et nous nous intéressons le plus souvent à leurs composants qui eux sont créés via des structs et des enums. Les systèmes quant à eux, sont définis au travers de simples fonctions.

Tout ceci vous paraîtra plus clair à mesure que nous avancerons dans le développement de notre jeu.

Préparatifs

Assez de blabla, il est temps de coder! Premièrement, nous devons préparer notre environnement de développement. Nous avons besoin d’une version récente de Rust pour continuer (exécutez simplement rustup update). Pour référence, j’utilise Rust 1.86.0.

Commençons par créer un package binaire en rust et installer Bevy comme dépendance :

1
2
3
cargo new bevy_platformer
cd bevy_platformer/
cargo add bevy

Note à propos des performances

En développant notre jeu et y incorporant de nouvelles fonctionnalités, nous allons le faire tourner (en mode développement) pour tester des trucs. En Rust, les builds de développement sont sous-optimisés pour accélérer les temps de compilation, celà n’est généralement pas un problème sauf quand il s’agit de jeux vidéos. Ce manque d’optimisation peut causer des saccades et du lag. Pour éviter cela, nous configurons quelques optimisations pour notre profil de compilation de développement.

Activons les optimisations de niveau 1 pour notre propre code, et niveau 3 pour toutes nos dépendances. Il suffit d’ajouter ceci dans votre Cargo.toml :

1
2
3
4
5
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

Ces optimisations ralongent le temps de compilation, donc nous devons rajouter d’autres options pour le réduire. La liste complète de ces paramètres est décrite dans la documentation officielle de Bevy mais nous allons seulement en utiliser qu’un sous-ensemble. Libre à vous d’en ajouter d’autres en fonction de vos besoins.

Mettons à jour notre Cargo.toml pour activer la fonctionnalité de liaison dynamique2 de Bevy :

1
2
[dependencies]
bevy = { version = "0.16", features = ["dynamic_linking"] }

Avec cette configuration, nous compilerons quasi instantanément tout en nous affranchissant des problèmes de performance évoqués ci-dessus. Je (NDT : l’auteurice original·e) fais tourner ça sur un MacBook M2 Pro de 2023, mais votre expérience peut varier ; vous pourriez vouloir ajouter d’autres paramètres de configuration si vous trouvez vos builds3 lents.

Démarrage

Commençons par créer notre application de jeu et ajoutons-y un système simple. éditons le fichier src/main.rs, et remplaçons son contenu par celui-ci :

1
2
3
4
5
6
7
8
9
10
11
12
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup() {
    println!("Hello, World!")
}

Nous venons d’initialiser une App Bevy et y avons ajouté deux choses :

À présent si nous lançons notre programme avec cargo run, nous devrions voir apparaître une fenêtre vide (puisque nous n’avons rien affiché pour l’instant), et « Hello, world! » écrit dans la console parmi d’autres lignes de logs.

Notons que la première compilation peut prendre un certain temps puisque nous compilons la totalité du moteur de jeu, les prochaines compilations devraient être plus rapides.

Afficher des plateformes

Au bout du compte, nous voulons que notre jeu fasse plus vrai avec de jolis sprites ; mais pour l’instant, nous allons produire des formes monochromes pour représenter nos entités. Commençons par réécrire notre fonction setup() pour faire apparaître une plateforme rectangulaire.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn setup(mut commands: Commands) {
    use bevy::color::palettes::css::LIMEGREEN;

    commands.spawn((
        Transform {
            translation: Vec3::splat(0.0),
            scale: Vec3::new(50.0, 100.0, 1.0),
            ..Default::default()
        },
        Sprite {
            color: LIMEGREEN.into(),
            ..Default::default()
        },
    ));
}

Ce code a de nombreux effets, étudions-les un-à-un.

Premièrement, on voit que notre système accepte un argument. Les systèmes, dans Bevy, peuvent avoir des arguments basés sur la manière dont ils interagissent avec l’univers du jeu. Les arguments doivent être de types que Bevy comprend (sinon nous obtiendrons des erreurs à la compilation) et quand Bevy exécute ces systèmes, il leur fournira les paramètres attendus, un peu comme dans un modèle d’injection de dépendance4. Nous avons ici accepté un seul argument mais on peut écrire des systèmes pouvant en demander plusieurs (jusqu’à une certaine limite), et Bevy va les alimenter avec des valeurs pendant l’exécution. Ces arguments sont aussi une manière pour le système de « demander » à l’univers du jeu une liste d’entité et de composants qu’il a besoin traiter.

Ici nous demandons un argument de type Commands qui nous permet d’ajouter une file de changement à l’univers du jeu, comme faire apparaître des entités.

Ensuite, nous utilisons la méthode spawn() pour créer une entité. On pourrait penser que nous créons une entité sous forme d’un tuple (Sprite, Transform), mais c’est inexact. Comme mentionné plus tôt, les entités sont toutes du même type (Entity), qui n’est qu’une enveloppe autour d’un identifiant numérique, et sont discriminées en fonction de leurs composants. Ce que nous faisons ici, en vérité, c’est créer une entité (ce qui est transparent pour nous) en lui attachant des composants contenus dans ce tuple.

Pour comprendre plus en détails, considérons ceci :

1
commands.spawn(Transform::from_xyz(0.0, 0.0, 0.0));

Transform est un composant, et dans le morceau de code ci-dessus, nous créons une entité avec un composant Transform attaché à elle. Nous pouvons aussi attacher plusieurs composants à une entité en créant une structure Platform implémentant le trait Bundle tel que:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Bundle)]
struct Platform {
    sprite: Sprite,
    transform: Transform,
}

commands.spawn(Platform {
    transform: Transform::from_xyz(0.0, 0.0, 0.0),
    sprite: Sprite {
        color: Color::LIME_GREEN,
        ..Default::default()
    },
});

À présent, nous créons une entité contenant deux composant, un de type Transform et l’autre est de type Sprite contenus dans un Platform. On peut attacher autant de composants que l’on veut à une entité en utilisant un tuple au prix d’une complexité, c’est pourquoi Bevy propose le concept de « Bundle5 » qui permet d’organiser des groupes de composants plus pratiques.

Platform est un groupe de composants permettant d’afficher des sprites correspondant à nos plateformes ne contenant que deux composants, Sprite et Transform. Sprite permet d’afficher une texture à l’écran, pour l’instant un simple à-plat de couleur citron-vert, ses autres propriétés prenant les valeurs par défaut.

Pour comprendre Transform, nous devons en premier lieu appréhender le système de coordonnées de Bevy. Il s’appuie sur une géométrie Cartésienne à trois dimensions avec ces propriétés :

Il s’agit d’un système droitier (right handed), si l’on devait poser sa main sur l’écran comme l’illustre cette image:

Système de coordonnées des différents logiciels 3D

Crédits: Bevy Cheatbook

Bevy utilise le système tri-dimensionnel même pour les jeux en 2D, dans un jeu en 2d, on peut considérer l’axe Z comme une pile de feuilles où la coordonnée Z décrit l’ordre de ces feuilles dans une pile.

Transform nous permet de positionner et de mettre à l’échelle notre entité, dans ce système de coordonnées. la propriété Transform.translation est de type Vec3 qui est simplement un vecteur composé de trois valeurs représentant les coordonnées (x, y, z), pour indiquer où l’entité doit apparaître. Transform.scale est aussi de type Vec3 et nous permet de mettre notre entité à l’échelle afin de modifier sa taille. Par défaut, un sprite est représenté par une boite ayant la forme cube de 1.0 de côté6 ; en utilisant la propriété scale, on peut la redimensionner en multipliant ses côtés par rapport aux axes.

Avec cette connaissance nous pouvons comprendre ce bout de code :

1
2
3
4
5
6
7
8
9
10
11
commands.spawn(Platform {
    sprite: Sprite {
        color: LIMEGREEN.into(),
        ..Default::default()
    },
    transform: Transform {
        translation: Vec3::splat(0.0),
        scale: Vec3::new(50.0, 100.0, 1.0),
        ..Default::default()
    },
});

Ici le code crée une entité avec différents composants attachés à elle. Le Sprite est utilisé pour créer un cube de 1 pixel de coté (que nous appellerons rectangle puisque nous l’afficherons en 2D) et le colorier en vert clair. L’autre composant, Transform est utilisé pour afficher sa forme depuis le point d’origine (0, 0, 0) − qui est le centre de l’écran − et sa mise à l’échelle telle que :

À présent, si on lance cargo run on verra… rien, c’est parce que nous n’avons ajouté aucune caméra à notre application pour afficher les entités. Ajoutons-la :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[derive(Bundle)]
struct Platform {
    sprite: Sprite,
    transform: Transform,
}

fn setup(mut commands: Commands) {
    commands.spawn(Platform {
        sprite: Sprite {
            color: LIMEGREEN.into(),
            ..Default::default()
        },
        transform: Transform {
            translation: Vec3::splat(0.0),
            scale: Vec3::new(50.0, 100.0, 1.0),
            ..Default::default()
        },
    });

    commands.spawn(Camera2d);
}

La dernière ligne demande à Bevy de créer une entité composée des caractéristiques propres à une vue en 2D (Camera2d), c’est à dire avec une projection orthogonale (par opposition à une projection en perspective), comme ci-dessous.

À présent si on lance le jeu, nous devrions voir ceci :

Rectangle vert

Un rectangle vert, Fascinant.

Ce n’est pas grand chose mais nous avons couvert des notions fondamentales qui se montreront bien utile par la suite. Avant de poursuivre, une petite note: le rectangle est centré sur l’écran − ses coordonnées ont été positionnées à (0, 0, 0) − cela signifie que le point d’ancrage dans le système de transformation de Bevy est au centre, par défaut. Bevy centre nos entités autour du point aux coordonnées fournies et les affiche à moité à gauche et à droite, et à moité en dessous et au dessus de ce point:

Point d’ancrage

Point d’ancrage

Il est important de rappeler que les points d’ancrages varient d’un moteur à l’autre. À présent parlons des projection.

Projections

Alors que nous créons l’univers de notre jeu et l’alimentons avec des entités, ce que les joueureuses voient à l’écran dépend de notre caméra et de la projection qu’elle utilise. Cela consiste à projeter des éléments d’un espace tri-dimensionnel (l’univers du jeu) sur un plan en deux dimensions (l’écran de l’utilisateurice). Il en existe un certain nombres mais les deux plus communes sont:

La première est similaire à la perception humaine où la profondeur (distance depuis les yeux) d’un objet détermine sa taille apparente.

Dans la seconde, la profondeur est totalement ignorée. On peut imaginer que chaque rayon de lumière (les lignes de projection) émis par les objets arrivent parallèlement sur une toile (et perpendiculairement à celle-ci), et donc la taille apparente des-dits ne varie pas avec la distance.

Projections

Source: StackOverflow

Comme nous voulons construire un jeu à défilement horizontal en 2D, nous voulons ignorer la profondeur, donc la projection orthogonale correspond exactement à ce dont nous avons besoin et la caméra que nous venons d’initialiser fournit cette dernière par défaut. Bevy supporte également la projection en perspective que l’on peut configurer en utilisant Camera3d (documentation)

Positionnement des plateformes

Ensuite, nous voulons générer quelques plateformes et les positionner sur le « sol », mais d’abord, nous avons besoin de définir ce qu’est le sol. Évidement, il devrait se situer sur le bas de la fenêtre ne notre jeu. Pour faire simple, nous allons aussi fixer la taille de la fenêtre. Définissons donc quelques constantes :

1
2
3
4
5
const WINDOW_WIDTH: f32 = 1024.0;
const WINDOW_HEIGHT: f32 = 720.0;

const WINDOW_BOTTOM_Y: f32 = WINDOW_HEIGHT / -2.0;
const WINDOW_LEFT_X: f32 = WINDOW_WIDTH / -2.0;

Rappel: dans le système de coordonnées de Bevy, l’origine est au centre de l’écran donc si on traverse la moité de la fenêtre vers le bas (Y négatif), nous en atteindrons le bas. La même logique peut être utilisée pour atteindre le bord gauche de la fenêtre.

Maintenant, fixons la taille de la fenêtre en utilisant les constantes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Platformer".to_string(),
                resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
                resizable: false,
                ..Default::default()
            }),
            ..Default::default()
        }))
        .add_systems(Startup, setup)
        .run();
}

Ici, nous configurons le WindowPlugin qui est ajouté automatiquement par DefaultPlugins, pour attribuer une taille arbitraire et fixe à la fenêtre en faisant en sorte que l’utilisateurice ne puisse pas la changer, tout en lui donnant un titre personnalisé.

Ensuite, modifions le système startup pour créer trois plateformes de tailles différentes placées tout au long du bas de l’écran :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
fn setup(mut commands: Commands) {
    commands.spawn(Platform {
        sprite: Sprite {
            color: LIMEGREEN.into(),
            ..Default::default()
        },
        transform: Transform {
            translation: Vec3::new(-100.0, WINDOW_BOTTOM_Y + (200.0 / 2.0), 0.0),
            scale: Vec3::new(75.0, 200.0, 1.0),
            ..Default::default()
        },
    });

    commands.spawn(Platform {
        sprite: Sprite {
            color: LIMEGREEN.into(),
            ..Default::default()
        },
        transform: Transform {
            translation: Vec3::new(100.0, WINDOW_BOTTOM_Y + (350.0 / 2.0), 0.0),
            scale: Vec3::new(50.0, 350.0, 1.0),
            ..Default::default()
        },
    });

    commands.spawn(Platform {
        sprite: Sprite {
            color: LIMEGREEN.into(),
            ..Default::default()
        },
        transform: Transform {
            translation: Vec3::new(350.0, WINDOW_BOTTOM_Y + (250.0 / 2.0), 0.0),
            scale: Vec3::new(150.0, 250.0, 1.0),
            ..Default::default()
        },
    });

    commands.spawn(Camera2d);
}

Notons comment nous avons positionnés chaque plateforme en décalant ses coordonnées sur l’axe Y par la moité de sa hauteur depuis le bas de la fenêtre. C’est à cause de la position centrale de son point d’ancrage. Si nous avions seulement attribué la coordonnée Y pour être en bas de l’écran, la moité de la plateforme se situerait en dessous de l’écran.

Nous avons de la duplication de code, que nous allons nettoyer plus tard. Maintenant, lorsque vous lancez le jeu, vous devriez voir ceci :

Trois rectangles

Trois rectangles !

Même si nous allons finir par afficher de vrai sprites rendons tout ceci plus agréable à regarder en attendant. Commençons par définir quelques constantes pour les couleurs que nous allons utiliser :

1
2
3
const COLOR_BACKGROUND: Color = Color::linear_rgb(0.29, 0.31, 0.41);
const COLOR_PLATFORM: Color = Color::linear_rgb(0.13, 0.13, 0.23);
const COLOR_PLAYER: Color = Color::linear_rgb(0.60, 0.55, 0.60);

Nous venons tout juste de définir trois couleurs en utilisant des composantes RGB (de 0.0 à 1.0 au lieu de 0 à 255). Changeons à présent la couleur pour chacun des composants Platform :

1
2
3
4
5
6
7
8
9
10
11
commands.spawn(Platform {
    sprite: Sprite {
        color: COLOR_PLATFORM,
        ..Default::default()
    },
    transform: Transform {
        translation: Vec3::new(-100.0, WINDOW_BOTTOM_Y + (200.0 / 2.0), 0.0),
        scale: Vec3::new(75.0, 200.0, 1.0),
        ..Default::default()
    },
});

Éditons la configuration de l’application pour changer la couleur de fond de la fenêtre

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
    App::new()
        .insert_resource(ClearColor(COLOR_BACKGROUND))
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Platformer".to_string(),
                resolution: WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT),
                resizable: false,
                ..Default::default()
            }),
            ..Default::default()
        }))
        .add_systems(Startup, setup)
        .run();
}

Nous venons d’insérer une ressource dans l’application pour y parvenir. Les ressources sont un concept important de Bevy et on en parlera davantage dans les billets suivants. Contentons-nous pour l’instant d’ajouter cette ligne sachant qu’elle changera juste la couleur d’arrière plan. À présent, si vous lancez l’application vous devriez voir de nouvelles couleurs.

Enfin, créons un disque pour représenter notre joueur. Modifions setup() de cette manière :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    // ...

    commands.spawn((
        Mesh2d(meshes.add(Circle::default())),
        MeshMaterial2d(materials.add(ColorMaterial::from_color(COLOR_PLAYER))),
        Transform {
            translation: Vec3::new(WINDOW_LEFT_X + 100.0, WINDOW_BOTTOM_Y + 30.0, 0.0),
            scale: Vec3::new(30.0, 30.0, 30.0),
            ..Default::default()
        },
    ));
}

La signature de notre système a subit quelques changements, il accepte dorénavant deux arguments de plus, deux différentes ressources, et les utilise pour afficher un disque à l’écran en créant une entité composée d’un Mesh2d, d’un MeshMaterial2d et d’un Transform (que nous connaissons déjà, on le positionne arbitrairement vers la gauche de l’écran). Les deux autres resources déterminent l’aspect de notre disque, sa forme et son matériau. Je n’entrerai pas dans le détails des mesh et des matériaux pour deux raisons :

Les ressources, en revanche, sont très pertinentes et seront abordées dans un prochain article. En lançant le jeu, nous devrions voir ceci:

une capture d'écran d'un disque et trois rectangles

Bien joué! C’est le bon moment de faire une pause. Notre disque est en train de léviter, mais nous allons rectifier ceci dans le prochain article quand nous ajouterons de la physique à notre jeu. À bientôt!

Le code source de cette partie est disponible sur Codeberg.

NDT : Le code lié à l’article original se trouve sur le dépôt Github de l’auteurice original·e.

  1. NDT : Rustacé (rustacean) est le surnom que se donnent certain·e·s praticien·ne·s du langage Rust. 

  2. liaison dynamique: Il s’agit d’utiliser une bibliothèque de code externe à un programme (fichiers .dll sous Windows, .so sous linux ou dérivés d’UNIX, .dylib sous MacOS). 

  3. Ici, builds peut vouloir aussi bien désigner le temps de compilation que la performance à l’exécution du programme compilé. 

  4. Injection de dependence: il s’agit, d’une technique de programmation permettant de retirer des fonction la responsabilité de l’allocation et de la libération d’une ressource qu’elle doit utiliser, en allouant la ressource en dehors de ces fonctions. 

  5. Dans l’article original, il était question de SpriteBundle fourni par l’API de Bevy, mais depuis la version 0.15.0 les développeureuses peuvent écrire leurs propres Bundles. Par la suite SpriteBundle a été supprimé de Bevy dans la version 0.16.0. 

  6. NDT: Ici on ne donne pas vraiment d’unité puisque sans affichage, on ne peut ni parler de point, centimètre ou même pixel.