Créer un jeu avec Flutter et Flame

1. Introduction

Découvrez comment créer un jeu de plates-formes avec Flutter et Flame. Inspiré par Doodle Jump, le jeu Doodle Dash vous permet de jouer avec Dash (la mascotte Flutter) ou avec son meilleur ami Sparky (la mascotte Firebase) pour sauter le plus haut possible sur des plates-formes.

Points abordés

  • Créer un jeu multiplate-forme dans Flutter.
  • Créer des composants de jeu réutilisables que vous pouvez afficher et mettre à jour via la boucle de jeu Flame.
  • Contrôler et animer le mouvement de vos personnages (appelés lutins) via la physique du jeu.
  • Ajouter et gérer la détection de collision.
  • Définir les commandes de jeu par saisie au clavier ou saisie tactile.

Conditions préalables

Cet atelier de programmation suppose que vous avez une certaine expérience de Flutter. Dans le cas contraire, vous devriez d'abord vous familiariser avec ses principes de base en suivant l'atelier de programmation Créer sa première application Flutter.

Objectifs de l'atelier

Dans cet atelier de programmation, vous allez apprendre à créer le jeu Doodle Dash. Dans ce jeu de plates-formes, incarnez Dash (la mascotte Flutter) ou son meilleur ami Sparky (la mascotte Firebase) (même si la suite de cet atelier de programmation fait référence à Dash, les étapes s'appliquent également à Sparky). Le jeu propose les fonctionnalités suivantes :

  • Un lutin qui peut se déplacer à l'horizontale et à la verticale
  • Des plates-formes générées de façon aléatoire
  • Un effet de gravité qui attire le lutin vers le bas
  • Des menus de jeu
  • Des commandes intégrées (pause et reprise, par exemple)
  • L'enregistrement de votre score

Gameplay

Pour jouer à Doodle Dash, déplacez Dash vers la gauche ou la droite, sautez sur les plates-formes et utilisez les bonus pour accroître ses pouvoirs tout au long du jeu. Pour commencer le jeu, vous devez choisir le niveau de difficulté (de 1 à 5), puis cliquer sur Start (Démarrer).

d1e75aa0e05c526.gif

Niveaux

Le jeu comporte cinq niveaux. À chaque nouveau niveau (après le niveau 1), vous pouvez débloquer de nouvelles fonctionnalités.

  • Niveau 1 (par défaut) : génère les plates-formes NormalPlatform et SpringBoard. À sa création, une plate-forme a 20 % de chances d'être mobile.
  • Niveau 2 (score ≥ à 20) : ajoute une BrokenPlatform sur laquelle vous ne pouvez sauter qu'une seule fois.
  • Niveau 3 (score ≥ à 40) : débloque le bonus NooglerHat. Cette plate-forme spéciale dure cinq secondes et augmente la puissance de saut de Dash de 2,5 fois la vélocité normale. Pendant ces cinq secondes, Dash porte un chapeau de Noogleur.
  • Niveau 4 (score ≥ à 80) : débloque le bonus Rocket. Avec cette nouvelle plate-forme représentée par une fusée, Dash est invincible. Elle augmente aussi la puissance de saut de Dash de 3,5 fois la vélocité normale.
  • Niveau 5 (score ≥ à 100) : débloque la plate-forme Enemy. Si Dash touche un ennemi, la partie est automatiquement terminée.

Types de plates-formes par niveau

Niveau 1 (par défaut)

NormalPlatform

SpringBoard

Niveau 2 (score ≥ à 20)

Niveau 3 (score ≥ à 40)

Niveau 4 (score ≥ à 80)

Niveau 5 (score ≥ à 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

Fin de la partie

La partie se termine de deux façons :

  • Dash tombe au-dessous de la partie inférieure de l'écran.
  • Dash touche un ennemi (les ennemis sont générés au niveau 5).

Bonus

Les bonus améliorent les capacités du personnage. Par exemple, ils augmentent sa vélocité de saut et/ou le rendent invincible aux ennemis. Doodle Dash propose deux types de bonus. Vous ne pouvez activer qu'un seul bonus à la fois.

  • Le chapeau de Noogleur augmente la puissance de saut de Dash de 2,5 fois la hauteur normale. Pendant ce bonus, Dash porte un chapeau de Noogleur.
  • Avec le bonus fusée, Dash est invincible aux plates-formes ennemies (toucher un ennemi n'a aucun effet). Le bonus augmente aussi sa puissance de saut de 3,5 fois la hauteur normale. Dash vole dans une fusée jusqu'à ce que la gravité ait raison de sa vélocité et le fasse atterrir sur une plate-forme.

2. Obtenir le code de démarrage de l'atelier de programmation

a3c16fc17be25f6c.png Téléchargez la version initiale du projet sur GitHub :

  1. Dans la ligne de commande, clonez le dépôt GitHub dans un répertoire flutter-codelabs :
git clone https://github.com/flutter/codelabs.git flutter-codelabs

Le code de cet atelier de programmation se trouve dans le répertoire flutter-codelabs/flame-building-doodle-dash. Le répertoire comporte le code du projet finalisé pour chaque étape de l'atelier de programmation.

a3c16fc17be25f6c.png Importer l'application de départ

  • Importez le répertoire flutter-codelabs/flame-building-doodle-dash/step_02 dans l'IDE de votre choix.

a3c16fc17be25f6c.png Installer les packages

  • Tous les packages nécessaires (comme Flame) ont déjà été ajoutés au fichier pubspec.yaml du projet. Si votre IDE n'installe pas automatiquement les dépendances, ouvrez un terminal de ligne de commande et exécutez la commande suivante depuis la racine du projet Flutter pour extraire les dépendances du projet :
flutter pub get

Configurer votre environnement de développement Flutter

Pour cet atelier de programmation, vous avez besoin des éléments suivants :

3. À la découverte du code

Parcourez ensuite le code.

Examinez le fichier lib/game/doodle_dash.dart comportant le jeu Doodle Dash qui étend FlameGame. Vous utilisez l'instance FlameGame (le premier composant de base de Flame, semblable à un Scaffold Flutter) pour enregistrer vos composants. Elle permet de rendre et de mettre à jour l'ensemble des composants enregistrés durant le gameplay. Vous pouvez la considérer comme l'épine dorsale de votre jeu.

Que sont les composants ? De la même manière qu'une application Flutter est constituée de Widgets, un FlameGame est composé de Components, à savoir tous les éléments de base qui forment le jeu. (Comme pour les widgets Flutter, ils peuvent également avoir des composants enfants.) Le lutin du personnage, l'arrière-plan du jeu et l'objet responsable de générer les nouveaux éléments de jeu (comme les ennemis) sont tous des composants. En réalité, le FlameGame est en soi un Component. Flame l'appelle le "Flame Component System".

Les composants sont hérités d'une classe Component abstraite. Implémentez les méthodes abstraites de Component pour créer la mécanique de la classe FlameGame. Par exemple, les méthodes suivantes sont régulièrement implémentées dans Doodle Dash :

  • onLoad : réalise l'initialisation asynchrone d'un composant (comme la méthode initState de Flutter).
  • update : met un composant à jour à chaque tick de la boucle de jeu (comme la méthode build de Flutter).

De plus, la méthode add enregistre les composants avec le moteur Flame.

Ainsi, le fichier lib/game/world.dart comporte la classe World qui étend ParallaxComponent pour rendre l'arrière-plan du jeu. Cette classe reprend une liste de composants Image et les affiche sous la forme de couches. Chaque couche se déplace à une vélocité différente pour un rendu plus réaliste. La classe DoodleDash comporte une instance ParallaxComponent et l'ajoute au jeu dans la méthode onLoad de Doodle Dash :

lib/game/world.dart

class World extends ParallaxComponent<DoodleDash> {
 @override
 Future<void> onLoad() async {
   parallax = await gameRef.loadParallax(
     [
       ParallaxImageData('game/background/06_Background_Solid.png'),
       ParallaxImageData('game/background/05_Background_Small_Stars.png'),
       ParallaxImageData('game/background/04_Background_Big_Stars.png'),
       ParallaxImageData('game/background/02_Background_Orbs.png'),
       ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
       ParallaxImageData('game/background/01_Background_Squiggles.png'),
     ],
     fill: LayerFill.width,
     repeat: ImageRepeat.repeat,
     baseVelocity: Vector2(0, -5),
     velocityMultiplierDelta: Vector2(0, 1.2),
   );
 }
}

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
   with HasKeyboardHandlerComponents, HasCollisionDetection {
 ...
 final World _world = World();
 ...

 @override
 Future<void> onLoad() async {
   await add(_world);
   ...
 }
}

Gestion de l'état

Le répertoire lib/game/managers comporte trois fichiers permettant de gérer l'état de Doodle Dash, à savoir game_manager.dart, object_manager.dart et level_manager.dart.

La classe GameManager (dans game_manager.dart) permet de suivre l'état de jeu global et les scores.

La classe ObjectManager (dans object_manager.dart) permet de gérer où et quand les plates-formes sont générées et supprimées. Vous allez compléter cette classe ultérieurement.

Enfin, la classe LevelManager (dans level_manager.dart) permet de gérer le niveau de difficulté du jeu, ainsi que les configurations qui s'appliquent lorsque les joueurs passent au niveau supérieur. Le jeu comporte cinq niveaux de difficulté. Le joueur passe au niveau supérieur lorsqu'il atteint un certain score. Plus le niveau augmente, plus le jeu est difficile et plus Dash doit sauter haut. La gravité est constante tout au long du jeu. De ce fait, la vitesse de saut augmente progressivement afin de tenir compte des distances accrues.

Le score du joueur augmente dès qu'il franchit une plate-forme. Lorsque le joueur atteint un certain nombre de points, le jeu passe au niveau supérieur et débloque de nouvelles plates-formes spéciales qui accentuent la difficulté et le caractère ludique du jeu.

4. Ajouter un player au jeu

Cette étape permet d'ajouter un personnage au jeu (Dash, dans ce cas). Le player commande le personnage et l'ensemble de la logique est contenue dans la classe Player (dans le fichier player.dart). La classe Player étend la classe SpriteGroupComponent de Flame qui contient les méthodes abstraites à remplacer pour implémenter la logique personnalisée. Cela consiste à charger les éléments et les lutins, à positionner le player (à l'horizontale et à la verticale), à configurer la détection de collision et à accepter l'entrée utilisateur.

Chargement des éléments

Dash apparaît avec différents lutins qui représentent les différentes versions du personnage et des bonus. Par exemple, les icônes ci-dessous représentent Dash et Sparky, de face et de côté (gauche et droit).

Le SpriteGroupComponent de Flame permet de gérer plusieurs états de lutin avec la propriété sprites comme vous pouvez le constater dans la méthode _loadCharacterSprites.

a3c16fc17be25f6c.png Dans la classe Player, ajoutez les lignes suivantes à la méthode onLoad pour charger les éléments de lutin et configurer les états de lutin du Player de face :

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await _loadCharacterSprites();                                      // Add this line
  current = PlayerState.center;                                       // Add this line
}

Examinez le code permettant de charger les lutins et des éléments dans _loadCharacterSprites. Ce code peut être directement implémenté dans la méthode onLoad. Cependant, le placer dans une autre méthode permet d'organiser le code source et d'améliorer sa lisibilité. Cette méthode permet d'effectuer un mappage avec la propriété sprites qui associe chaque état de personnage avec un élément de lutin chargé, comme cela est représenté ci-dessous :

lib/game/sprites/player.dart

Future<void> _loadCharacterSprites() async {
   final left = await gameRef.loadSprite('game/${character.name}_left.png');
   final right = await gameRef.loadSprite('game/${character.name}_right.png');
   final center =
       await gameRef.loadSprite('game/${character.name}_center.png');
   final rocket = await gameRef.loadSprite('game/rocket_4.png');
   final nooglerCenter =
       await gameRef.loadSprite('game/${character.name}_hat_center.png');
   final nooglerLeft =
       await gameRef.loadSprite('game/${character.name}_hat_left.png');
   final nooglerRight =
       await gameRef.loadSprite('game/${character.name}_hat_right.png');

   sprites = <PlayerState, Sprite>{
     PlayerState.left: left,
     PlayerState.right: right,
     PlayerState.center: center,
     PlayerState.rocket: rocket,
     PlayerState.nooglerCenter: nooglerCenter,
     PlayerState.nooglerLeft: nooglerLeft,
     PlayerState.nooglerRight: nooglerRight,
   };
 }

Mettre à jour le composant "player"

Flame appelle la méthode update d'un composant à chaque tick (ou cadre) de la boucle d'événements pour rappeler tous les composants qui ont été modifiés (comme la méthode build de Flutter). Ensuite, ajoutez la logique à la méthode update de la classe Player pour positionner le personnage à l'écran.

a3c16fc17be25f6c.png Ajoutez le code suivant à la méthode update de Player pour calculer la vélocité et la position actuelles du personnage :

lib/game/sprites/player.dart

 void update(double dt) {
                                                             // Add lines from here...
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;                              // ... to here.

   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {                  // Add lines from here...
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }                                                                   // ... to here.

   // Core gameplay: Add gravity

   position += _velocity * dt;                                       // Add this line
   super.update(dt);
 }

Avant de déplacer le player, la méthode update vérifie que le jeu ne se trouve pas dans un état non jouable (le player ne peut pas bouger), comme l'état initial (premier chargement du jeu) ou l'état de partie terminée.

Si le jeu se trouve dans un état jouable, la position de Dash est calculée à l'aide de l'équation new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick) ou comme représenté dans le code :

 position += _velocity * dt

Lorsque vous créez Doodle Dash, il est également essentiel de s'assurer que les bords latéraux n'ont pas de limites. Dash peut sauter du côté gauche de l'écran pour revenir côté droit, et inversement.

7068325e8b2f35fc.gif

Pour implémenter cette fonctionnalité, vérifiez si la position de Dash a dépassé la bordure gauche ou droite de l'écran, et repositionnez-le du côté opposé,le cas échéant.

Événements clés

À l'origine, Doodle Dash s'exécute sur le Web et les ordinateurs. Il doit donc accepter la saisie au clavier qui permet au joueur de contrôler les mouvements du personnage. La méthode onKeyEvent permet aux composants Player de reconnaître les appuis sur les flèches afin de déterminer si Dash doit regarder et se déplacer vers la gauche ou la droite.

Dash regarde vers la gauche lorsqu'il se déplace dans cette direction

Dash regarde vers la droite lorsqu'il se déplace dans cette direction

Implémentez ensuite la fonction de déplacement à l'horizontale de Dash (comme défini dans la variable _hAxisInput). Vous devez aussi faire en sorte que Dash regarde dans la direction dans laquelle il se déplace.

a3c16fc17be25f6c.png Modifier les méthodes moveLeft et moveRight de la classe Player pour définir la direction actuelle de Dash :

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;

   current = PlayerState.left;                                      // Add this line

   _hAxisInput += movingLeftInput;                                  // Add this line

 }

 void moveRight() {
   _hAxisInput = 0;

   current = PlayerState.right;                                     // Add this line

   _hAxisInput += movingRightInput;                                 // Add this line

 }

a3c16fc17be25f6c.png Modifiez la méthode onKeyEvent de la classe Player pour appeler respectivement la méthode moveLeft ou moveRight lorsque l'utilisateur appuie sur la flèche vers la gauche ou la droite :

lib/game/sprites/player.dart

@override
 bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
   _hAxisInput = 0;

                                                             // Add lines from here...
   if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
     moveLeft();
   }

   if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
     moveRight();
   }                                                                   // ... to here.

   // During development, it's useful to "cheat"
   if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
     // jump();
   }

   return true;
 }

La classe Player est maintenant fonctionnelle, et le jeu Doodle Dash peut l'utiliser.

a3c16fc17be25f6c.png dans le fichier DoodleDash, importez sprites.dart pour rendre la classe Player disponible :

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.png Créez une instance Player dans la classe DoodleDash :

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  DoodleDash({super.children});

  final World _world = World();
  LevelManager levelManager = LevelManager();
  GameManager gameManager = GameManager();
  int screenBufferSpace = 300;
  ObjectManager objectManager = ObjectManager();

  late Player player;                                                // Add this line
  ...
}

a3c16fc17be25f6c.png Initialisez et configurez la vitesse de saut du Player en fonction du niveau de difficulté sélectionné par le joueur, puis ajoutez le composant Player au FlameGame. Complétez la méthode setCharacter avec le code suivant :

lib/game/doodle_dash.dart

void setCharacter() {
  player = Player(                                           // Add lines from here...
     character: gameManager.character,
     jumpSpeed: levelManager.startingJumpSpeed,
   );
  add(player);                                                         // ... to here.
}

a3c16fc17be25f6c.png Appelez la méthode setCharacter au début d'initializeGameStart.

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.png Dans initializeGameStart, appelez également resetPosition sur le player afin de le placer sur la position de départ à chaque démarrage du jeu.

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    ...
  }

a3c16fc17be25f6c.png Exécutez l'application. Lancez le jeu : Dash apparaît à l'écran.

ed15a9c6762595c9.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

5. Ajouter des plates-formes

Cette étape vise à ajouter des plates-formes (permettant à Dash de se poser et de sauter), ainsi que la logique de détection de collision pour déterminer à quel moment Dash doit sauter.

Tout d'abord, examiner la classe abstraite Platform :

lib/game/sprites/platform.dart

abstract class Platform<T> extends SpriteGroupComponent<T>
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  bool isMoving = false;

  Platform({
    super.position,
  }) : super(
          size: Vector2.all(100),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    await add(hitbox);
  }
}

Qu'est-ce qu'un masque de collision ?

Chaque composant "platform" introduit dans Doodle Dash étend la classe abstraite Platform<T>, qui est un SpriteComponent avec un masque de collision. Le masque de collision permet à un composant "sprite" de détecter toute collision avec d'autres objets dotés d'un masque de collision. Flame accepte plusieurs formes de masque de collision (rectangles, cercles, et polygones, par exemple). Par exemple, Doodle Dash utilise un masque de collision rectangulaire pour les plates-formes et un masque de collision circulaire pour Dash. Flame gère la formule mathématique qui détermine la collision.

La classe Platform ajoute un masque de collision et des rappels de collision à tous les sous-types.

Ajouter une plate-forme standard

La classe Platform ajoute les plates-formes dans le jeu. Une plate-forme standard est représentée par quatre images choisies de manière aléatoire : un écran, un téléphone, un terminal ou un ordinateur portable. Le choix de l'image n'affecte pas le comportement de la plate-forme.

NormalPlatform

a3c16fc17be25f6c.png Ajoutez une plate-forme statique standard en insérant une énumération NormalPlatformState et une classe NormalPlatform :

lib/game/sprites/platform.dart

enum NormalPlatformState { only }                            // Add lines from here...

class NormalPlatform extends Platform<NormalPlatformState> {
  NormalPlatform({super.position});

  final Map<String, Vector2> spriteOptions = {
    'platform_monitor': Vector2(115, 84),
    'platform_phone_center': Vector2(100, 55),
    'platform_terminal': Vector2(110, 83),
    'platform_laptop': Vector2(100, 63),
  };

  @override
  Future<void>? onLoad() async {
    var randSpriteIndex = Random().nextInt(spriteOptions.length);

    String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);

    sprites = {
      NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
    };

    current = NormalPlatformState.only;

    size = spriteOptions[randSprite]!;
    await super.onLoad();
  }
}                                                                      // ... to here.

Ensuite, générez les plates-formes avec lesquelles le personnage va interagir.

La classe ObjectManager étend la classe Component de Flame et génère les objets Platform tout au long du jeu. Implémentez la fonction pour générer les plates-formes dans les méthodes update et onMount d'ObjectManager.

a3c16fc17be25f6c.png Générez les plates-formes dans la classe ObjectManager en créant une méthode appelée _semiRandomPlatform. Pour le moment, renvoyez simplement un NormalPlatform (vous mettrez à jour cette méthode pour renvoyer différents types de plates-formes par la suite) :

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {             // Add lines from here...
    return NormalPlatform(position: position);
}                                                                      // ... to here.

a3c16fc17be25f6c.png Remplacez la méthode update d'ObjectManager et utilisez la méthode _semiRandomPlatform pour générer une plate-forme et l'ajouter en jeu :

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void update(double dt) {
   final topOfLowestPlatform =
       _platforms.first.position.y + _tallestPlatformHeight;

   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;

   if (topOfLowestPlatform > screenBottom) {
     var newPlatY = _generateNextY();
     var newPlatX = _generateNextX(100);
     final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
     add(nextPlat);

     _platforms.add(nextPlat);

     gameRef.gameManager.increaseScore();

     _cleanupPlatforms();
     // Losing the game: Add call to _maybeAddEnemy()
     // Powerups: Add call to _maybeAddPowerup();
   }

   super.update(dt);
 }                                                                     // ... to here.

Répétez l'opération avec la méthode onMount d'ObjectManager, de sorte que la méthode _semiRandomPlatformgénère une plate-forme de départ et l'ajoute au jeu lorsqu'il s'exécute pour la première fois.

a3c16fc17be25f6c.png Ajoutez la méthode onMount avec le code suivant :

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void onMount() {
   super.onMount();

   var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

   var currentY =
       gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;

   for (var i = 0; i < 9; i++) {
     if (i != 0) {
       currentX = _generateNextX(100);
       currentY = _generateNextY();
     }
     _platforms.add(
       _semiRandomPlatform(
         Vector2(
           currentX,
           currentY,
         ),
       ),
     );

     add(_platforms[i]);
   }
 }                                                                     // ... to here.

Ainsi, dans le code suivant, la méthode configure permet au jeu Doodle Dash de reconfigurer des distances minimales et maximales entre les plates-formes, et d'activer les plates-formes spéciales lorsque le niveau de difficulté augmente :

lib/game/managers/object_manager.dart

 void configure(int nextLevel, Difficulty config) {
    minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
    maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;

    for (int i = 1; i <= nextLevel; i++) {
      enableLevelSpecialty(i);
    }
  }

L'instance DoodleDash (dans la méthode initializeGameStart) crée un objet ObjectManager qui est initialisé, configuré selon le niveau de difficulté, puis ajouté au jeu Flame :

lib/game/doodle_dash.dart

  void initializeGameStart() {
    gameManager.reset();

    if (children.contains(objectManager)) objectManager.removeFromParent();

    levelManager.reset();

    player.resetPosition();

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    add(objectManager);

    objectManager.configure(levelManager.level, levelManager.difficulty);
  }

L'ObjectManager réapparaît dans la méthode checkLevelUp. Lorsque le joueur passe au niveau supérieur, l'ObjectManager reconfigure les paramètres pour générer les plates-formes en fonction du niveau de difficulté.

lib/game/doodle_dash.dart

  void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);
    }
  }

a3c16fc17be25f6c.png Effectuez un hot reload 7f9a9e103c7b5e5.png (ou redémarrez si le test est réalisé sur le Web) pour appliquer les modifications. (Enregistrez les fichiers, utilisez le bouton de votre IDE ou saisissez r dans la ligne de commande pour effectuer un hot reload.) Lancez le jeu. Dash et certaines plates-formes apparaissent à l'écran :

7c6a6c6e630c42ce.png

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

6. Gameplay de base

Vous venez d'implémenter les widgets individuels Player et Platform. Vous pouvez maintenant commencer à rassembler tous les éléments. Cette étape vise à implémenter la fonctionnalité de base, la détection de collision et les mouvements de caméra.

Gravité

Pour que le jeu soit plus réaliste, Dash doit être soumis à la force de gravité qui l'attire vers le bas à chaque saut. Dans notre version de Doodle Dash, la gravité est une valeur positive toujours constante qui attire systématiquement Dash vers le bas. Cependant, vous serez libre de modifier la gravité pour créer d'autres effets par la suite.

a3c16fc17be25f6c.png Dans la classe Player, ajoutez une propriété _gravity avec une valeur de 9 :

lib/game/sprites/player.dart

class Player extends SpriteGroupComponent<PlayerState>
    with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {

  ...

  Character character;
  double jumpSpeed;
  final double _gravity = 9;                                         // Add this line

  @override
  Future<void> onLoad() async {
    ...
  }
  ...
}

a3c16fc17be25f6c.png Modifiez la méthode update de Player pour ajouter la variable _gravity et modifier la vélocité verticale de Dash :

lib/game/sprites/player.dart

 void update(double dt) {
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;
   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }

   _velocity.y += _gravity;                                          // Add this line

   position += _velocity * dt;
   super.update(dt);
 }

Détection de collision

Flame propose une détection de collision prête à l'emploi. Pour l'activer dans votre jeu Flame, ajoutez le mixin HasCollisionDetection. Si vous examinez la classe DoodleDash, vous constatez que le mixin s'y trouve déjà :

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
    ...
}

Ensuite, ajoutez la détection de collision à chaque composant de jeu en utilisant le mixin CollisionCallbacks. Ce mixin délivre un accès au composant au rappel onCollision. Une collision entre deux objets dotés d'un masque de collision déclenche un rappel onCollision et transmet une référence aux objets concernés, afin que vous puissiez implémenter une logique concernant le comportement qu'ils doivent adopter.

Rappelez-vous qu'à l'étape précédente, la classe abstraite Platform comportait déjà le mixin CollisionCallbacks et un masque de collision. La classe Player comporte également le mixin CollisionCallbacks. Il vous suffit donc d'ajouter un CircleHitbox à la classe Player. Le masque de collision de Dash est un cercle, car sa forme est plus circulaire que rectangulaire.

a3c16fc17be25f6c.png Dans la classe Player, importez sprites.dart pour qu'il puisse accéder aux différentes classes Platform :

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.png Ajoutez un CircleHitbox à la méthode onLoad de la classe Player :

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await add(CircleHitbox());                                         // Add this line

  await _loadCharacterSprites();
  current = PlayerState.center;
}

Dash a besoin d'une méthode "jump" pour pouvoir sauter lorsqu'il touche une plate-forme.

a3c16fc17be25f6c.png Ajoutez une méthode jump qui accepte un specialJumpSpeed facultatif :

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

a3c16fc17be25f6c.png Remplacez la méthode onCollision de Player en ajoutant le code suivant :

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);
   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     }
   }
 }

Ce rappel appelle la méthode jump de Dash dès que le personnage tombe vers le bas et touche la partie supérieure d'une NormalPlatform. L'instruction isMovingDown && isCollidingVertically garantit que Dash se déplace vers le haut d'une plate-forme à l'autre sans déclencher de saut.

Mouvements de caméra

La caméra doit suivre Dash lorsqu'il se déplace vers le haut et rester statique lorsque le personnage chute vers le bas.

Dans Flame, si le "monde" est plus grand que l'écran, utilisez le worldBounds de la caméra pour définir des limites qui indiquent à Flame quelle partie du monde afficher. Pour donner l'impression que la caméra se déplace vers le haut tout en restant fixe à l'horizontale, adaptez les limites haut/bas à chaque actualisation, en fonction de la position du player, et conservez des limites gauche/droite identiques.

a3c16fc17be25f6c.png Dans la classe DoodleDash, ajoutez le code suivant à la méthode update pour que la caméra puisse suivre Dash pendant le gameplay :

lib/game/doodle_dash.dart

@override
  void update(double dt) {
    super.update(dt);

    if (gameManager.isIntro) {
      overlays.add('mainMenuOverlay');
      return;
    }

    if (gameManager.isPlaying) {
      checkLevelUp();

                                                            // Add lines from here...
      final Rect worldBounds = Rect.fromLTRB(
        0,
        camera.position.y - screenBufferSpace,
        camera.gameSize.x,
        camera.position.y + _world.size.y,
      );
      camera.worldBounds = worldBounds;

      if (player.isMovingDown) {
        camera.worldBounds = worldBounds;
      }

      var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
      if (!player.isMovingDown && isInTopHalfOfScreen) {
        camera.followComponent(player);
      }                                                               // ... to here.
    }
  }

Vous devez ensuite réinitialiser la position du Player et les limites de la caméra à chaque redémarrage du jeu.

a3c16fc17be25f6c.png Ajoutez le code suivant dans la méthode initializeGameStart :

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...
    levelManager.reset();

                                                        // Add the lines from here...
    player.reset();
    camera.worldBounds = Rect.fromLTRB(
      0,
      -_world.size.y,
      camera.gameSize.x,
      _world.size.y +
          screenBufferSpace,
    );
    camera.followComponent(player);
                                                                      // ... to here.

   player.resetPosition();
    ...
  }

Augmenter la vitesse de saut à chaque niveau

Pour le dernier élément du gameplay de base, la vitesse de saut de Dash doit augmenter avec le niveau de difficulté, de même que les distances qui séparent les plates-formes générées.

a3c16fc17be25f6c.png Ajoutez un appel à la méthode setJumpSpeed et indiquez la vitesse de saut associé au niveau actuel :

lib/game/doodle_dash.dart

void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);

      player.setJumpSpeed(levelManager.jumpSpeed);                   // Add this line
    }
  }

a3c16fc17be25f6c.png Effectuez un hot reload 7f9a9e103c7b5e5.png (ou redémarrez si vous êtes sur le Web) pour appliquer les modifications. (Enregistrez les fichiers, utilisez le bouton de votre IDE ou saisissez r dans la ligne de commande pour effectuer un hot reload.) :

2bc7c856064d74ca.gif

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

7. Focus sur les plates-formes

ObjectManager génère désormais des plates-formes sur lesquelles Dash peut sauter. Vous pouvez maintenant proposer différentes plates-formes spéciales parfaitement ludiques.

Ajoutez les classes BrokenPlatform et SpringBoard. Comme leur nom l'indique, la BrokenPlatform se brise après un saut et la SpringBoard est un trampoline qui permet à Dash de sauter plus haut plus rapidement.

BrokenPlatform

SpringBoard

Comme la classe Player, la représentation de l'état actuel de ces deux classes "platform" dépend des enums.

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

La modification de l'état current d'une plate-forme modifie également le lutin qui apparaît dans le jeu. Définissez le mappage entre l'énumération State et les composants Image de la propriété sprites pour déterminer le lien entre chaque lutin et chaque état.

a3c16fc17be25f6c.png Ajoutez une énumération BrokenPlatformState et la classe BrokenPlatform :

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }                // Add lines from here...

class BrokenPlatform extends Platform<BrokenPlatformState> {
  BrokenPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <BrokenPlatformState, Sprite>{
      BrokenPlatformState.cracked:
          await gameRef.loadSprite('game/platform_cracked_monitor.png'),
      BrokenPlatformState.broken:
          await gameRef.loadSprite('game/platform_monitor_broken.png'),
    };

    current = BrokenPlatformState.cracked;
    size = Vector2(115, 84);
  }

  void breakPlatform() {
    current = BrokenPlatformState.broken;
  }
}                                                                     // ... to here.

a3c16fc17be25f6c.png Ajoutez une énumération SpringState et la classe SpringBoard :

lib/game/sprites/platform.dart

enum SpringState { down, up }                                // Add lines from here...

class SpringBoard extends Platform<SpringState> {
  SpringBoard({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <SpringState, Sprite>{
      SpringState.down:
          await gameRef.loadSprite('game/platform_trampoline_down.png'),
      SpringState.up:
          await gameRef.loadSprite('game/platform_trampoline_up.png'),
    };

    current = SpringState.up;

    size = Vector2(100, 45);
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isCollidingVertically) {
      current = SpringState.down;
    }
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);

    current = SpringState.up;
  }
}                                                                      // ... to here.

Activez ensuite ces plates-formes spéciales dans ObjectManager. Ces plates-formes spéciales ne doivent pas nécessairement apparaître en permanence dans le jeu. Appliquer un facteur de probabilité à leur génération : 15 % pour SpringBoard et 10 % pour BrokenPlatform.

a3c16fc17be25f6c.png Dans la méthode _semiRandomPlatform d'ObjectManager et avant que l'instruction renvoie une NormalPlatform, ajouter le code suivant pour conditionner l'affichage d'une plate-forme spéciale :

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {
   if (specialPlatforms['spring'] == true &&                 // Add lines from here...
       probGen.generateWithProbability(15)) {
     return SpringBoard(position: position);
   }

   if (specialPlatforms['broken'] == true &&
       probGen.generateWithProbability(10)) {
     return BrokenPlatform(position: position);
   }                                                                   // ... to here.

   return NormalPlatform(position: position);
}

L'un des plaisirs du jeu consiste à débloquer de nouveaux défis et de nouvelles fonctionnalités lorsque vous passez au niveau supérieur.

Vous pouvez intégrer le trampoline dès le niveau 1 et proposer de débloquer la BrokenPlatform lorsque Dash atteint le niveau 2, pour augmenter légèrement la difficulté.

a3c16fc17be25f6c.png Dans la classe ObjectManager, modifiez la méthode enableLevelSpecialty (actuellement un bouchon) en ajoutant une instruction switch qui active les plates-formes SpringBoard au niveau 1 et BrokenPlatform au niveau 2 :

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {                                           // Add lines from here...
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
  }                                                                    // ... to here.
}

a3c16fc17be25f6c.png Permettez ensuite aux plates-formes de se déplacer à l'horizontale. Dans la classe abstraite Platform, ajoutez la méthode _move suivante :

lib/game/sprites/platform.dart

void _move(double dt) {
    if (!isMoving) return;

    final double gameWidth = gameRef.size.x;

    if (position.x <= 0) {
      direction = 1;
    } else if (position.x >= gameWidth - size.x) {
      direction = -1;
    }

    _velocity.x = direction * speed;

    position += _velocity * dt;
}

Lorsque la plate-forme est en mouvement, elle change de sens lorsqu'elle atteint la bordure de l'écran de jeu. Comme pour Dash, la position de la plate-forme est déterminée en multipliant _direction par la speed de la plate-forme afin d'obtenir la vélocité. Multipliez ensuite la vélocité par le time-elapsed et ajoutez la distance obtenue à la position actuelle de la plate-forme.

a3c16fc17be25f6c.png Remplacez la méthode update de la classe Platform pour appeler la méthode _move :

lib/game/sprites/platform.dart

@override
void update(double dt) {
  _move(dt);
  super.update(dt);
}

a3c16fc17be25f6c.png Pour déclencher la Platform en mouvement, configurez de façon aléatoire le paramètre booléen isMoving sur true à 20 % du temps dans la méthode onLoad.

lib/game/sprites/platform.dart

@override
Future<void>? onLoad() async {
  await super.onLoad();

  await add(hitbox);

  final int rand = Random().nextInt(100);                            // Add this line
  if (rand > 80) isMoving = true;                                    // Add this line
}

a3c16fc17be25f6c.png Enfin, dans le Player, modifier la méthode onCollision de la classe Player pour identifier les collisions avec une Springboard ou une BrokenPlatform. Notez qu'une SpringBoard appelle un jump avec un multiplicateur de vitesse de 2 et qu'une BrokenPlatform n'appelle un jump que si l'état est .cracked (et non .broken) (le personnage a déjà sauté dessus) :

lib/game/sprites/player.dart

@override
 void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);

   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     } else if (other is SpringBoard) {                      // Add lines from here...
       jump(specialJumpSpeed: jumpSpeed * 2);
       return;
     } else if (other is BrokenPlatform &&
         other.current == BrokenPlatformState.cracked) {
       jump();
       other.breakPlatform();
       return;
     }                                                                 // ... to here.
   }
 }

a3c16fc17be25f6c.png Redémarrez l'application et lancez une partie pour afficher les plates-formes en mouvement (SpringBoard et BrokenPlatform).

d4949925e897f665.gif

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

8. Fin de la partie

Cette étape permet d'ajouter les conditions de fin de partie du jeu Doodle Dash. Le joueur peut perdre la partie de deux façons :

  1. Dash rate une plate-forme et tombe au-dessous de la partie inférieure de l'écran.
  2. Dash touche une plate-forme Enemy.

Avant d'implémenter ces conditions de fin de partie, vous devez ajouter une logique qui définit l'état du jeu Doodle Dash comme gameOver.

a3c16fc17be25f6c.png Dans la classe DoodleDash, ajoutez une méthode onLose appelée dès qu'une partie doit prendre fin. Elle définit l'état du jeu, supprime le player à l'écran et active le menu ou la superposition **Game Over** (Partie terminée).

lib/game/sprites/doodle_dash.dart

 void onLose() {                                             // Add lines from here...
    gameManager.state = GameState.gameOver;
    player.removeFromParent();
    overlays.add('gameOverOverlay');
  }                                                                    // ... to here.

Menu Game Over (Partie terminée) :

6a79b43f4a1f780d.png

a3c16fc17be25f6c.png En haut de la méthode update de DoodleDash, ajoutez le code suivant pour arrêter d'actualiser la partie lorsque l'état du jeu est GameOver :

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   super.update(dt);

   if (gameManager.isGameOver) {                             // Add lines from here...
     return;
   }                                                                   // ... to here.
   ...
}

a3c16fc17be25f6c.png Dans la méthode update, appelez également onLose lorsque le player est tombé au-dessous la partie inférieure de l'écran.

lib/game/sprites/doodle_dash.dart

@override
 void update(double dt) {
   ...

   if (gameManager.isPlaying) {
     checkLevelUp();

     final Rect worldBounds = Rect.fromLTRB(
       0,
       camera.position.y - screenBufferSpace,
       camera.gameSize.x,
       camera.position.y + _world.size.y,
     );
     camera.worldBounds = worldBounds;
     if (player.isMovingDown) {
       camera.worldBounds = worldBounds;
     }

     var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
     if (!player.isMovingDown && isInTopHalfOfScreen) {
       camera.followComponent(player);
     }

                                                             // Add lines from here...
     if (player.position.y >
         camera.position.y +
             _world.size.y +
             player.size.y +
             screenBufferSpace) {
       onLose();
     }                                                                 // ... to here.
   }
 }

Les ennemis prennent de nombreuses formes et de nombreuses tailles. Dans Doodle Dash, ils sont signalés par une icône en forme de poubelle ou de dossier endommagé. Les joueurs doivent éviter de toucher ces ennemis sous peine de perdre aussitôt la partie.

Enemy

a3c16fc17be25f6c.png Créez un type de plate-forme ennemie en ajoutant une énumération EnemyPlatformState et la classe EnemyPlatform :

lib/game/sprites/platform.dart

enum EnemyPlatformState { only }                             // Add lines from here...

class EnemyPlatform extends Platform<EnemyPlatformState> {
  EnemyPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    var randBool = Random().nextBool();
    var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';

    sprites = <EnemyPlatformState, Sprite>{
      EnemyPlatformState.only:
          await gameRef.loadSprite('game/$enemySprite.png'),
    };

    current = EnemyPlatformState.only;

    return super.onLoad();
  }
}                                                                      // ... to here.

La classe EnemyPlatform étend le supertype Platform. L'ObjectManager génère et gère les plates-formes ennemies comme toute autre plate-forme.

a3c16fc17be25f6c.png Dans ObjectManager, ajoutez le code suivant pour générer et gérer les plates-formes ennemies :

lib/game/managers/object_manager.dart

final List<EnemyPlatform> _enemies = [];                    // Add lines from here...
void _maybeAddEnemy() {
  if (specialPlatforms['enemy'] != true) {
    return;
  }
  if (probGen.generateWithProbability(20)) {
    var enemy = EnemyPlatform(
      position: Vector2(_generateNextX(100), _generateNextY()),
    );
    add(enemy);
    _enemies.add(enemy);
    _cleanupEnemies();
  }
}

void _cleanupEnemies() {
  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;

  while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
    remove(_enemies.first);
    _enemies.removeAt(0);
  }
}                                                                      // ... to here.

L'ObjectManager gère une liste d'objets ennemis, _enemies. _maybeAddEnemy génère des ennemis avec une probabilité de 20 % et ajoute l'objet à la liste des ennemis. La méthode _cleanupEnemies() supprime les objets EnemyPlatform obsolètes qui ne sont plus visibles.

a3c16fc17be25f6c.png Sous ObjectManager, générez des plates-formes ennemies en appelant _maybeAddEnemy() dans la méthode update :

lib/game/managers/object_manager.dart

@override
void update(double dt) {
  final topOfLowestPlatform =
      _platforms.first.position.y + _tallestPlatformHeight;

  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;
  if (topOfLowestPlatform > screenBottom) {
    var newPlatY = _generateNextY();
    var newPlatX = _generateNextX(100);
    final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
    add(nextPlat);

    _platforms.add(nextPlat);
    gameRef.gameManager.increaseScore();

    _cleanupPlatforms();
    _maybeAddEnemy();                                                 // Add this line
  }

  super.update(dt);
}

a3c16fc17be25f6c.png Ajoutez la méthode onCollision de Player pour vérifier s'il existe une collision avec une EnemyPlatform. Le cas échéant, appelez la méthode onLose().

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform) {                           // Add lines from here...
      gameRef.onLose();
      return;
    }                                                                 // ... to here.

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }
  }

a3c16fc17be25f6c.png Enfin, de modifier la méthode enableLevelSpecialty d'ObjectManager pour ajouter le niveau 5 à l'instruction switch :

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
    case 5:                                                  // Add lines from here...
      enableSpecialty('enemy');
      break;                                                           // ... to here.
  }
}

a3c16fc17be25f6c.png Le jeu est maintenant plus complexe. Effectuez un hot reload 7f9a9e103c7b5e5.png pour appliquer les modifications. (Enregistrez les fichiers, utilisez le bouton de votre IDE ou saisissez r dans la ligne de commande pour effectuer un hot reload.) :

Attention aux ennemis en forme de dossiers endommagés. Ils sont sournois et se confondent avec l'arrière-plan !

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

9. Bonus

Cette étape permet d'ajouter des fonctionnalités de jeu optimisées afin d'améliorer Dash tout au long de la partie. Doodle Dash comporte deux options de bonus : le chapeau de Noogleur et la fusée. Vous pouvez considérer ces bonus comme un autre type de plates-formes spéciales. Lorsque Dash saute et touche un bonus, il prend la forme du chapeau de Noogleur ou de la fusée, et sa vitesse augmente.

NooglerHat

Rocket

Les chapeaux de Noogleur sont générés au niveau 3, lorsque le joueur atteint un score ≥ à 40. Lorsque Dash touche ce bonus, il porte un chapeau de Noogleur et bénéficie d'une accélération de 2,5 fois la vitesse normale. L'effet dure cinq secondes.

Les fusées sont générées au niveau 4, lorsque le joueur atteint un score ≥ à 80. Lorsque Dash touche ce bonus, son lutin est remplacé par une fusée. Il bénéficie d'une accélération de 3,5 fois la vitesse normale jusqu'à ce qu'il se pose sur une plate-forme. Avec le bonus fusée, Dash est également invincible aux ennemis.

Les lutins en forme de chapeau de Noogleur et de fusée étendent la classe abstraite PowerUp. Comme pour la classe abstraite Platform, la classe abstraite PowerUp représentée ci-dessous comprend la taille et un masque de collision.

lib/game/sprites/powerup.dart

abstract class PowerUp extends SpriteComponent
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  double get jumpSpeedMultiplier;

  PowerUp({
    super.position,
  }) : super(
          size: Vector2.all(50),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    await add(hitbox);
  }
}

a3c16fc17be25f6c.png Créez une classe Rocket qui étend la classe abstraite PowerUp. Lorsque Dash touche une fusée, il bénéficie d'une accélération de 3,5 fois sa vitesse normale.

lib/game/sprites/powerup.dart

class Rocket extends PowerUp {                               // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 3.5;

  Rocket({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/rocket_1.png');
    size = Vector2(50, 70);
  }
}                                                                      // ... to here.

a3c16fc17be25f6c.png Créez une classe NooglerHat qui étend la classe abstraite PowerUp. Lorsque Dash touche un NooglerHat, il bénéficie d'une accélération de 2,5 fois sa vitesse normale. Cette accélération dure cinq secondes.

lib/game/sprites/powerup.dart

class NooglerHat extends PowerUp {                          // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 2.5;

  NooglerHat({
    super.position,
  });

  final int activeLengthInMS = 5000;

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/noogler_hat.png');
    size = Vector2(75, 50);
  }
}                                                                      // ... to here.

Vous venez d'implémenter les bonus NooglerHat et Rocket. mettez maintenant à jour l'ObjectManager pour les générer dans le jeu.

a3c16fc17be25f6c.png Modifiez la classe ObjectManger pour ajouter une liste qui conserve les bonus générés ainsi que les deux nouvelles méthodes (_maybePowerup et _cleanupPowerups) pour générer et supprimer les nouvelles plates-formes "powerup".

lib/game/managers/object_manager.dart

final List<PowerUp> _powerups = [];                          // Add lines from here...

 void _maybeAddPowerup() {
   if (specialPlatforms['noogler'] == true &&
       probGen.generateWithProbability(20)) {
     var nooglerHat = NooglerHat(
       position: Vector2(_generateNextX(75), _generateNextY()),
     );
     add(nooglerHat);
     _powerups.add(nooglerHat);
   } else if (specialPlatforms['rocket'] == true &&
       probGen.generateWithProbability(15)) {
     var rocket = Rocket(
       position: Vector2(_generateNextX(50), _generateNextY()),
     );
     add(rocket);
     _powerups.add(rocket);
   }

   _cleanupPowerups();
 }

 void _cleanupPowerups() {
   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;
   while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
     if (_powerups.first.parent != null) {
       remove(_powerups.first);
     }
     _powerups.removeAt(0);
   }
 }                                                                     // ... to here.

La méthode _maybeAddPowerup génère un chapeau de Noogleur à 20 % du temps ou une fusée à 15 % du temps. La méthode _cleanupPowerups est appelée pour supprimer les bonus qui se trouvent au-dessous de la limite inférieure de l'écran.

a3c16fc17be25f6c.png Modifiez la méthode update d'ObjectManager pour appeler _maybePowerup à chaque tick de la boucle de jeu.

lib/game/managers/object_manager.dart

@override
  void update(double dt) {
    final topOfLowestPlatform =
        _platforms.first.position.y + _tallestPlatformHeight;

    final screenBottom = gameRef.player.position.y +
        (gameRef.size.x / 2) +
        gameRef.screenBufferSpace;

    if (topOfLowestPlatform > screenBottom) {
      var newPlatY = _generateNextY();
      var newPlatX = _generateNextX(100);
      final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
      add(nextPlat);

      _platforms.add(nextPlat);

      gameRef.gameManager.increaseScore();

      _cleanupPlatforms();
      _maybeAddEnemy();
      _maybeAddPowerup();                                            // Add this line
    }

    super.update(dt);
  }

a3c16fc17be25f6c.png Modifiez la méthode enableLevelSpecialty pour ajouter deux nouveaux cas à l'instruction "switch". Le premier permet d'activer les NooglerHat au niveau 3 et le second d'activer les Rocket au niveau 4 :

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
    switch (level) {
      case 1:
        enableSpecialty('spring');
        break;
      case 2:
        enableSpecialty('broken');
        break;
      case 3:                                               // Add lines from here...
        enableSpecialty('noogler');
        break;
      case 4:
        enableSpecialty('rocket');
        break;                                                        // ... to here.
      case 5:
        enableSpecialty('enemy');
        break;
    }
  }

a3c16fc17be25f6c.png Ajouter les getters booléens suivants dans la classe Player. Plusieurs états permettent de représenter les cas où Dash active un bonus. Ces getters simplifient l'identification du bonus actif.

lib/game/sprites/player.dart

 bool get hasPowerup =>                                      // Add lines from here...
     current == PlayerState.rocket ||
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;

 bool get isInvincible => current == PlayerState.rocket;

 bool get isWearingHat =>
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;                             // ... to here.

a3c16fc17be25f6c.png Modifiez la méthode onCollision de Player afin de réagir à toute collision avec un NooglerHat ou une Rocket. Ce code garantit également que Dash ne peut activer un nouveau bonus que s'il en est dépourvu.

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform && !isInvincible) {
      gameRef.onLose();
      return;
    }

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }

    if (!hasPowerup && other is Rocket) {                    // Add lines from here...
      current = PlayerState.rocket;
      other.removeFromParent();
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    } else if (!hasPowerup && other is NooglerHat) {
      if (current == PlayerState.center) current = PlayerState.nooglerCenter;
      if (current == PlayerState.left) current = PlayerState.nooglerLeft;
      if (current == PlayerState.right) current = PlayerState.nooglerRight;
      other.removeFromParent();
      _removePowerupAfterTime(other.activeLengthInMS);
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    }                                                                  // ... to here.
  }

Lorsque Dash touche une fusée, le PlayerState bascule sur Rocket et permet de sauter avec un jumpSpeedMultiplier de 3,5.

Lorsque Dash touche un chapeau de Noogleur et en fonction de la direction du PlayerState actuel (.center, .left ou .right), le PlayerState bascule sur le PlayerState du Noogleur correspondant. Le personnage porte le chapeau de Noogleur et bénéficie d'un jumpSpeedMultiplier de 2,5. La méthode _removePowerupAfterTime supprime le bonus après cinq secondes et rebascule l'état du bonus du PlayerState sur center.

L'appel de other.removeFromParent supprime les plates-formes des lutins en forme de chapeau de Noogleur et de fusée à l'écran, afin de montrer que Dash a obtenu un bonus.

ede04fdfe074f471.gif

a3c16fc17be25f6c.png Modifiez les méthodes moveLeft et moveRight du player pour prendre en compte le lutin NooglerHat. Il n'est pas nécessaire de tenir compte du bonus Rocket, puisque son lutin se trouve toujours dans la même direction.

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerLeft;
   } else if (!hasPowerup) {                                           // ... to here.
     current = PlayerState.left;
   }                                                                  // Add this line
   _hAxisInput += movingLeftInput;
 }

 void moveRight() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerRight;
   } else if (!hasPowerup) {                                            //... to here.
     current = PlayerState.right;
   }                                                                  // Add this line
   _hAxisInput += movingRightInput;
 }

Dash est invincible aux ennemis lorsqu'il utilise le bonus Rocket, ce qui empêche de perdre la partie durant cette période.

a3c16fc17be25f6c.png Modifiez le rappel onCollision pour vérifier l'état isInvincible de Dash avant de déclencher une fin de partie lorsque le personnage touche une EnemyPlatform :

lib/game/sprites/player.dart

   if (other is EnemyPlatform && !isInvincible) {                 // Modify this line
     gameRef.onLose();
     return;
   }

a3c16fc17be25f6c.png Redémarrez l'application et lancez une partie pour afficher les bonus.

e1fece51429dae55.gif

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

10. Superpositions

Vous pouvez encapsuler un jeu Flame dans un widget pour simplifier son intégration avec d'autres widgets dans une application Flutter. Vous pouvez également afficher les widgets Flutter en superposition, en haut de votre jeu Flame. Cela est pratique pour les composants hors gameplay qui ne dépendent pas de la boucle de jeu, comme les menus, l'écran de pause, les boutons et les curseurs.

L'affichage du score dans le jeu ainsi que tous les menus de Doodle Dash sont des widgets Flutter standard (et non des composants Flame). Le code de tous les widgets se trouve dans lib/game/widgets. Par exemple, le menu Game Over (Partie terminée) est une simple colonne qui contient d'autres widgets (tels que Text et ElevatedButton), comme représenté dans le code suivant :

lib/game/widgets/game_over_overlay.dart

class GameOverOverlay extends StatelessWidget {
 const GameOverOverlay(this.game, {super.key});

 final Game game;

 @override
 Widget build(BuildContext context) {
   return Material(
     color: Theme.of(context).colorScheme.background,
     child: Center(
       child: Padding(
         padding: const EdgeInsets.all(48.0),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
           crossAxisAlignment: CrossAxisAlignment.center,
           children: [
             Text(
               'Game Over',
               style: Theme.of(context).textTheme.displayMedium!.copyWith(),
             ),
             const WhiteSpace(height: 50),
             ScoreDisplay(
               game: game,
               isLight: true,
             ),
             const WhiteSpace(
               height: 50,
             ),
             ElevatedButton(
               onPressed: () {
                 (game as DoodleDash).resetGame();
               },
               style: ButtonStyle(
                 minimumSize: MaterialStateProperty.all(
                   const Size(200, 75),
                 ),
                 textStyle: MaterialStateProperty.all(
                     Theme.of(context).textTheme.titleLarge),
               ),
               child: const Text('Play Again'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

Pour utiliser un widget en superposition dans un jeu Flame, définissez une propriété overlayBuilderMap sur GameWidget avec une key représentant la superposition (comme une String) et la value d'une fonction widget qui affiche un widget, comme représenté dans le code suivant :

lib/main.dart

GameWidget(
  game: game,
  overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
    'gameOverlay': (context, game) => GameOverlay(game),
    'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
    'gameOverOverlay': (context, game) => GameOverOverlay(game),
  },
)

Une fois ajoutée, la superposition peut être utilisée partout dans le jeu. Affichez une superposition avec overlays.add, puis masquez-la avec overlays.remove, comme représenté dans le code suivant :

lib/game/doodle_dash.dart

void resetGame() {
   startGame();
   overlays.remove('gameOverOverlay');
 }

 void onLose() {
   gameManager.state = GameState.gameOver;
   player.removeFromParent();
   overlays.add('gameOverOverlay');
 }

11. Compatibilité avec les mobiles

Développé sur Flutter et Flame, Doodle Dash s'exécute déjà sur les plates-formes compatibles avec Flutter. Mais jusqu'ici, Doodle Dash accepte seulement la saisie au clavier. Pour les appareils qui n'ont pas de clavier (comme les téléphones mobiles), vous pouvez facilement ajouter des commandes tactiles à l'écran, en superposition.

a3c16fc17be25f6c.png Ajoutez une variable d'état booléenne au GameOverlay qui détermine les cas où le jeu est exécuté sur une plate-forme mobile :

lib/game/widgets/game_overlay.dart

class GameOverlayState extends State<GameOverlay> {
 bool isPaused = false;

                                                                      // Add this line
 final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

 @override
 Widget build(BuildContext context) {
   ...
 }
}

Des boutons directionnels (vers la gauche et la droite) apparaissent désormais en superposition lorsque le jeu est exécuté sur un mobile. Dans la lignée de la logique des événements clés de l'étape 4, appuyer sur le bouton de gauche permet de déplacer Dash vers la gauche. Appuyer sur le bouton de droite permet de le déplacer vers la droite.

a3c16fc17be25f6c.png dans la méthode build de GameOverlay, ajoutez une section isMobile qui suivre le même comportement qu'à l'étape 4 : appuyer sur le bouton de gauche invoque moveLeft et appuyer sur le bouton de droite invoque moveRight. Relâcher le bouton appelle resetDirection. Dash ne se déplace plus à l'horizontale.

lib/game/widgets/game_overlay.dart

@override
 Widget build(BuildContext context) {
   return Material(
     color: Colors.transparent,
     child: Stack(
       children: [
         Positioned(... child: ScoreDisplay(...)),
         Positioned(... child: ElevatedButton(...)),
         if (isMobile)                                       // Add lines from here...
           Positioned(
             bottom: MediaQuery.of(context).size.height / 4,
             child: SizedBox(
               width: MediaQuery.of(context).size.width,
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveLeft();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_left, size: 64),
                       ),
                     ),
                   ),
                   Padding(
                     padding: const EdgeInsets.only(right: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveRight();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_right, size: 64),
                       ),
                     ),
                   ),
                 ],
               ),
             ),
           ),                                                          // ... to here.
         if (isPaused)
           ...
       ],
     ),
   );
 }

Et voilà ! Désormais, l'application Doodle Dash détecte automatiquement le type de plate-forme sur lequel il s'exécute et adapte ses saisies en conséquence.

a3c16fc17be25f6c.png Exécutez l'application sur iOS ou Android pour afficher les boutons directionnels.

7b0cac5fb69bc89.gif

Des problèmes ?

Si votre application ne s'exécute pas correctement, vérifiez que vous n'avez pas fait de faute de frappe. Si nécessaire, utilisez le code disponible via les liens ci-dessous pour résoudre le problème.

12. Étapes suivantes

Félicitations !

Vous avez terminé cet atelier de programmation et appris à utiliser le moteur de jeu Flame pour développer un jeu dans Flutter.

Points abordés

  • Utiliser le package Flame pour créer un jeu de plates-formes, y compris :
  • Ajouter un personnage
  • Ajouter différents types de plates-formes
  • Implémenter la détection de collision
  • Ajouter un composant "gravity"
  • Définir les mouvements de caméra
  • Créer des ennemis
  • Créer des bonus
  • Identifier la plate-forme sur laquelle le jeu s'exécute
  • Utiliser cette information pour basculer entre les commandes de jeu par saisie au clavier et par saisie tactile.

Ressources

Nous espérons que cet atelier vous aidera à créer des jeux dans Flutter !

Les ressources suivantes peuvent également vous être utiles et même constituer une source d'inspiration :