Présentation de Flame avec Flutter

1. Introduction

Flame est un moteur de jeu 2D basé sur Flutter. Dans cet atelier de programmation, vous allez créer un jeu inspiré de l'un des classiques des jeux vidéo des années 70, Breakout de Steve Wozniak. Vous allez utiliser les composants de Flame pour dessiner la batte, la balle et les briques. Vous allez utiliser les effets de Flame pour animer le mouvement de la chauve-souris et découvrir comment intégrer Flame au système de gestion des états de Flutter.

Une fois terminé, votre jeu devrait se présenter comme ce GIF animé, mais un peu plus lentement.

Enregistrement d'écran d'un jeu en cours de jeu. Le jeu a été considérablement accéléré.

Points abordés

  • Fonctionnement de base de Flame, en commençant par GameWidget.
  • Utiliser une boucle de jeu
  • Fonctionnement des Component de Flame Ils sont semblables aux Widget de Flutter.
  • Gérer les collisions
  • Utiliser des Effect pour animer des Component
  • Superposer des Widget Flutter sur un jeu Flame
  • Intégrer Flame à la gestion des états de Flutter

Ce que vous allez faire

Dans cet atelier de programmation, vous allez créer un jeu 2D à l'aide de Flutter et de Flame. Une fois terminé, votre jeu doit répondre aux exigences suivantes:

  • Fonctionner sur les six plates-formes compatibles avec Flutter: Android, iOS, Linux, macOS, Windows et le Web
  • Maintenez au moins 60 FPS à l'aide de la boucle de jeu de Flame.
  • Utilisez les fonctionnalités Flutter telles que le package google_fonts et flutter_animate pour recréer l'ambiance des jeux d'arcade des années 80.

2. Configurer votre environnement Flutter

Éditeur

Pour simplifier cet atelier de programmation, nous avons présumé que Visual Studio Code (VS Code) était votre environnement de développement. VS Code est sans frais et fonctionne sur toutes les principales plates-formes. Nous utilisons VS Code pour cet atelier de programmation, car les instructions renvoient par défaut aux raccourcis de VS Code. Les tâches deviennent plus simples: "cliquez sur ce bouton" ou "appuyez sur cette touche pour effectuer X" plutôt que "effectuez l'action appropriée dans votre éditeur pour effectuer X".

Vous pouvez utiliser l'éditeur de votre choix, comme Android Studio, les IDE IntelliJ, Emacs, Vim ou Notepad++. Tous fonctionnent avec Flutter.

Capture d'écran de VS Code avec du code Flutter

Choisir une cible de développement

Flutter produit des applications pour plusieurs plates-formes. Votre application peut s'exécuter sur l'un des systèmes d'exploitation suivants :

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • web

Il est courant de choisir un système d'exploitation comme cible de développement. Il s'agit du système d'exploitation sur lequel votre application s'exécute pendant le développement.

Dessin représentant un ordinateur portable et un téléphone relié à celui-ci par un câble. L'ordinateur portable est identifié comme

Par exemple, supposons que vous utilisiez un ordinateur portable Windows pour développer votre application Flutter. Vous choisissez ensuite Android comme cible de développement. Pour prévisualiser votre application, vous devez connecter un appareil Android à votre ordinateur portable Windows à l'aide d'un câble USB. Votre application en cours de développement s'exécute alors sur cet appareil Android ou dans un émulateur Android. Vous pouvez avoir choisi Windows comme cible de développement, ce qui exécute votre application en cours de développement comme une application Windows, en parallèle de l'éditeur.

Vous pourriez être tenté de choisir le Web comme cible de développement. Cela présente un inconvénient pendant le développement: vous perdez la fonctionnalité de hot reload avec état de Flutter. Flutter ne peut pas procéder au hot reload des applications Web pour le moment.

Faites votre choix avant de continuer. Vous pourrez toujours exécuter votre application sur d'autres systèmes d'exploitation par la suite. Choisir une cible de développement permet de simplifier la prochaine étape.

Installer Flutter

Les dernières instructions pour installer le SDK Flutter sont disponibles sur docs.flutter.dev.

Les instructions du site Web Flutter couvrent l'installation du SDK, ainsi que les outils associés à la cible de développement et les plug-ins de l'éditeur. Pour cet atelier de programmation, installez les logiciels suivants:

  1. SDK Flutter
  2. Visual Studio Code avec plug-in Flutter
  3. Logiciel de compilation pour la cible de développement de votre choix. (Vous avez besoin de Visual Studio pour cibler Windows ou de Xcode pour cibler macOS ou iOS)

Dans la section suivante, vous allez créer votre premier projet Flutter.

Si vous devez résoudre un problème, certaines des questions-réponses ci-dessous (sur StackOverflow) peuvent vous aider.

Questions fréquentes

3. Créer un projet

Créer votre premier projet Flutter

Pour ce faire, ouvrez VS Code et créez le modèle d'application Flutter dans un répertoire de votre choix.

  1. Lancez Visual Studio Code.
  2. Ouvrez la palette de commandes (F1, Ctrl+Shift+P ou Shift+Cmd+P), puis saisissez "flutter new". Lorsque l'invite s'affiche, sélectionnez la commande Flutter: New Project (Flutter : nouveau projet).

Capture d'écran de VS Code avec

  1. Sélectionnez Empty Application (Application vide). Choisissez un répertoire dans lequel créer votre projet. Il doit s'agir d'un répertoire qui ne nécessite pas d'autorisations élevées ni ne contient d'espace dans son chemin d'accès. Il peut s'agir, par exemple, de votre répertoire d'accueil ou de C:\src\.

Capture d'écran de VS Code avec l'application vide sélectionnée dans le nouveau flux d'application

  1. Nommez votre projet brick_breaker. Le reste de cet atelier de programmation part du principe que vous avez nommé votre application brick_breaker.

Capture d'écran de VS Code avec

Flutter crée le dossier de votre projet et VS Code l'ouvre. Vous allez maintenant remplacer le contenu de deux fichiers par une structure de base de l'application.

Copier et coller l'application d'origine

L'exemple de code fourni dans cet atelier de programmation est ajouté à votre application.

  1. Dans le volet de gauche de VS Code, cliquez sur Explorer (Explorateur) et ouvrez le fichier pubspec.yaml.

Capture d'écran partielle de VS Code avec des flèches soulignant l'emplacement du fichier pubspec.yaml

  1. Remplacez le contenu de ce fichier par le code ci-dessous :

pubspec.yaml.

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Le fichier pubspec.yaml définit les informations de base de votre application, comme sa version actuelle, ses dépendances et les éléments utilisés pour son implémentation.

  1. Ouvrez le fichier main.dart dans le répertoire lib/.

Capture d'écran partielle de VS Code avec une flèche indiquant l'emplacement du fichier main.dart

  1. Remplacez le contenu de ce fichier par le code ci-dessous :

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Exécutez ce code pour vérifier que tout fonctionne. Une nouvelle fenêtre s'ouvre, avec un arrière-plan noir vierge. Le pire jeu vidéo du monde s'affiche désormais à 60 FPS !

Capture d'écran montrant une fenêtre d'application brick_breaker complètement noire.

4. Créer le jeu

Évaluer le jeu

Un jeu en deux dimensions (2D) nécessite une zone de jeu. Vous allez créer une zone de dimensions spécifiques, puis utiliser ces dimensions pour dimensionner d'autres aspects du jeu.

Il existe plusieurs façons de disposer les coordonnées dans la zone de jeu. Selon une convention, vous pouvez mesurer la direction à partir du centre de l'écran, avec l'origine (0,0) au centre de l'écran. Les valeurs positives déplacent les éléments vers la droite le long de l'axe X et vers le haut le long de l'axe Y. Cette norme s'applique à la plupart des jeux actuels, en particulier ceux qui impliquent trois dimensions.

Lorsque le jeu Breakout d'origine a été créé, l'origine était définie en haut à gauche. La direction X positive est restée la même, mais l'axe Y a été inversé. La direction positive de l'axe X était vers la droite et celle de l'axe Y vers le bas. Pour rester fidèle à l'époque, ce jeu définit l'origine en haut à gauche.

Créez un fichier nommé config.dart dans un nouveau répertoire nommé lib/src. Ce fichier recevra d'autres constantes au cours des étapes suivantes.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Ce jeu aura une largeur de 820 pixels et une hauteur de 1 600 pixels. La zone de jeu est redimensionnée pour s'adapter à la fenêtre dans laquelle elle s'affiche, mais tous les composants ajoutés à l'écran respectent cette hauteur et cette largeur.

Créer une zone de jeu

Dans le jeu Breakout, la balle rebondit sur les murs de l'aire de jeu. Pour gérer les collisions, vous devez d'abord disposer d'un composant PlayArea.

  1. Créez un fichier nommé play_area.dart dans un nouveau répertoire nommé lib/src/components.
  2. Ajoutez ce qui suit à ce fichier.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Là où Flutter utilise des Widget, Flame utilise des Component. Alors que les applications Flutter consistent à créer des arborescences de widgets, les jeux Flame consistent à gérer des arborescences de composants.

C'est là une différence intéressante entre Flutter et Flame. L'arborescence de widgets de Flutter est une description éphémère conçue pour être utilisée pour mettre à jour la couche RenderObject persistante et modifiable. Les composants de Flame sont persistants et modifiables, et le développeur est censé les utiliser dans le cadre d'un système de simulation.

Les composants de Flame sont optimisés pour exprimer les mécanismes de jeu. Cet atelier de programmation commencera par la boucle de jeu, qui sera présentée à l'étape suivante.

  1. Pour éviter tout encombrement, ajoutez un fichier contenant tous les composants de ce projet. Créez un fichier components.dart dans lib/src/components et ajoutez le contenu suivant.

lib/src/components/components.dart

export 'play_area.dart';

La directive export joue le rôle inverse de import. Il déclare les fonctionnalités que ce fichier expose lorsqu'il est importé dans un autre fichier. Ce fichier comportera davantage d'entrées à mesure que vous ajouterez des composants lors des étapes suivantes.

Créer un jeu Flame

Pour éteindre les traits rouges de l'étape précédente, dérivez une sous-classe pour FlameGame de Flame.

  1. Créez un fichier nommé brick_breaker.dart dans lib/src et ajoutez le code suivant.

lib/src/brick_breaker.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

Ce fichier coordonne les actions du jeu. Lors de la création de l'instance de jeu, ce code configure le jeu pour qu'il utilise le rendu à résolution fixe. Le jeu se redimensionne pour remplir l'écran qui le contient et ajoute un format letterbox si nécessaire.

Vous exposez la largeur et la hauteur du jeu afin que les composants enfants, comme PlayArea, puissent se définir à la taille appropriée.

Dans la méthode onLoad remplacée, votre code effectue deux actions.

  1. Configure l'angle supérieur gauche comme point d'ancrage du viseur. Par défaut, viewfinder utilise le milieu de la zone comme point d'ancrage pour (0,0).
  2. Ajoute PlayArea à world. Le monde représente le monde du jeu. Il projette tous ses enfants via la transformation de vue de CameraComponent.

Afficher le jeu à l'écran

Pour voir toutes les modifications que vous avez apportées à cette étape, mettez à jour votre fichier lib/main.dart avec les modifications suivantes.

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

Une fois ces modifications effectuées, redémarrez le jeu. Le jeu doit ressembler à l'image ci-dessous.

Capture d&#39;écran montrant une fenêtre d&#39;application brick_breaker avec un rectangle de couleur sable au milieu de la fenêtre de l&#39;application

À l'étape suivante, vous allez ajouter une balle au monde et la faire bouger.

5. Afficher la balle

Créer le composant de la balle

Pour afficher une balle en mouvement à l'écran, vous devez créer un autre composant et l'ajouter au monde du jeu.

  1. Modifiez le contenu du fichier lib/src/config.dart comme suit.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

Le modèle de conception consistant à définir des constantes nommées en tant que valeurs dérivées réapparaîtra de nombreuses fois dans cet atelier de programmation. Vous pouvez ainsi modifier les gameWidth et gameHeight de niveau supérieur pour voir comment l'apparence du jeu change en conséquence.

  1. Créez le composant Ball dans un fichier nommé ball.dart dans lib/src/components.

lib/src/components/ball.dart

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

Vous avez précédemment défini PlayArea à l'aide de RectangleComponent. Il est donc logique que d'autres formes existent. CircleComponent, comme RectangleComponent, dérive de PositionedComponent. Vous pouvez donc positionner la balle à l'écran. Plus important encore, sa position peut être modifiée.

Ce composant introduit le concept de velocity, ou changement de position au fil du temps. La vitesse est un objet Vector2, car elle représente à la fois la vitesse et la direction. Pour mettre à jour la position, remplacez la méthode update, que le moteur de jeu appelle pour chaque frame. dt correspond à la durée entre le frame précédent et celui-ci. Vous pouvez ainsi vous adapter à des facteurs tels que des fréquences d'images différentes (60 Hz ou 120 Hz) ou des images longues en raison d'un calcul excessif.

Portez une attention particulière à la mise à jour position += velocity * dt. C'est ainsi que vous implémentez la mise à jour d'une simulation discrète du mouvement au fil du temps.

  1. Pour inclure le composant Ball dans la liste des composants, modifiez le fichier lib/src/components/components.dart comme suit.

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

Ajouter la balle au monde

Vous avez un ballon. Placez-la dans le monde et configurez-la pour qu'elle se déplace dans l'espace de jeu.

Modifiez le fichier lib/src/brick_breaker.dart comme suit.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;                                     // Add this import

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(                                                     // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;                                           // To here.
  }
}

Cette modification ajoute le composant Ball à world. Pour définir la position de la balle au centre de la zone d'affichage, le code réduit d'abord la taille du jeu de moitié, car Vector2 dispose de surcharges d'opérateurs (* et /) pour mettre à l'échelle un Vector2 en fonction d'une valeur scalaire.

Définir le velocity de la balle est plus complexe. L'objectif est de déplacer la balle vers le bas de l'écran dans une direction aléatoire à une vitesse raisonnable. L'appel de la méthode normalized crée un objet Vector2 défini dans la même direction que l'Vector2 d'origine, mais réduit à une distance de 1. Cela permet de maintenir la vitesse du ballon constante, quelle que soit la direction dans laquelle il se déplace. La vitesse de la balle est ensuite ajustée pour correspondre à un quart de la hauteur du jeu.

Pour obtenir ces différentes valeurs, vous devez procéder à des itérations, également appelées tests de jeu dans le secteur.

La dernière ligne active l'affichage de débogage, qui ajoute des informations supplémentaires à l'écran pour faciliter le débogage.

Lorsque vous exécutez le jeu, il doit se présenter comme suit.

Capture d&#39;écran montrant une fenêtre d&#39;application brick_breaker avec un cercle bleu au-dessus du rectangle beige. Le cercle bleu est annoté de chiffres indiquant sa taille et son emplacement à l&#39;écran.

Le composant PlayArea et le composant Ball contiennent tous deux des informations de débogage, mais les mattes d'arrière-plan recadrent les nombres de PlayArea. Les informations de débogage s'affichent pour tous les éléments, car vous avez activé debugMode pour l'ensemble de l'arborescence des composants. Vous pouvez également activer le débogage uniquement pour certains composants, si cela vous est plus utile.

Si vous redémarrez votre jeu plusieurs fois, vous remarquerez peut-être que la balle n'interagit pas tout à fait comme prévu avec les murs. Pour obtenir cet effet, vous devez ajouter une détection de collision, ce que vous ferez à l'étape suivante.

6. Bouger

Ajouter la détection de collision

La détection de collision ajoute un comportement permettant à votre jeu de reconnaître quand deux objets sont entrés en contact.

Pour ajouter la détection de collision au jeu, ajoutez le mixin HasCollisionDetection au jeu BrickBreaker, comme indiqué dans le code suivant.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;
  }
}

Cela permet de suivre les hitboxes des composants et de déclencher des rappels de collision à chaque tick de jeu.

Pour commencer à remplir les hitboxes du jeu, modifiez le composant PlayArea comme indiqué ci-dessous.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        children: [RectangleHitbox()],                          // Add this parameter
      );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Ajouter un composant RectangleHitbox en tant qu'enfant de RectangleComponent permet de créer une zone de collision pour la détection de collision correspondant à la taille du composant parent. Il existe un constructeur de fabrique pour RectangleHitbox appelé relative lorsque vous souhaitez une hitbox plus petite ou plus grande que le composant parent.

Faire rebondir la balle

Jusqu'à présent, l'ajout de la détection de collision n'a eu aucun impact sur le gameplay. Il change une fois que vous modifiez le composant Ball. C'est le comportement de la balle qui doit changer lorsqu'elle entre en collision avec le PlayArea.

Modifiez le composant Ball comme suit.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],                            // Add this parameter
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

Cet exemple apporte un changement majeur avec l'ajout du rappel onCollisionStart. Le système de détection de collision ajouté à BrickBreaker dans l'exemple précédent appelle ce rappel.

Tout d'abord, le code vérifie si Ball a heurté PlayArea. Cela semble redondant pour le moment, car il n'y a pas d'autres composants dans le monde du jeu. Cela changera à l'étape suivante, lorsque vous ajouterez une chauve-souris au monde. Il ajoute ensuite une condition else pour gérer les collisions de la balle avec des objets autres que la batte. Un rappel pour implémenter la logique restante, si vous le souhaitez.

Lorsque la balle entre en collision avec le mur du bas, elle disparaît de la surface de jeu, alors qu'elle est encore bien visible. Vous gérerez cet artefact à une étape ultérieure, en utilisant les effets de Flame.

Maintenant que la balle entre en collision avec les murs du jeu, il serait bien utile de donner au joueur une batte pour la frapper.

7. Frapper la balle avec la batte

Créer la chauve-souris

Pour ajouter une batte pour maintenir la balle en jeu dans le jeu,

  1. Insérez des constantes dans le fichier lib/src/config.dart comme suit.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

Les constantes batHeight et batWidth sont explicites. La constante batStep, en revanche, nécessite une explication. Pour interagir avec la balle dans ce jeu, le joueur peut faire glisser la batte avec la souris ou avec le doigt, selon la plate-forme, ou utiliser le clavier. La constante batStep configure la distance de déplacement de la chauve-souris pour chaque appui sur la flèche vers la gauche ou la droite.

  1. Définissez la classe de composant Bat comme suit.

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

Ce composant introduit quelques nouvelles fonctionnalités.

Tout d'abord, le composant Bat est un PositionComponent, et non un RectangleComponent ni un CircleComponent. Cela signifie que ce code doit afficher l'Bat à l'écran. Pour ce faire, il remplace le rappel render.

En examinant attentivement l'appel canvas.drawRRect (dessiner un rectangle arrondi), vous vous demandez peut-être où se trouve le rectangle. Offset.zero & size.toSize() exploite une surcharge operator & sur la classe Offset dart:ui qui crée des Rect. Cette abréviation peut vous dérouter au début, mais vous la verrez fréquemment dans le code Flutter et Flame de bas niveau.

Deuxièmement, ce composant Bat peut être déplacé à l'aide de l'index ou de la souris, en fonction de la plate-forme. Pour implémenter cette fonctionnalité, ajoutez le mixin DragCallbacks et remplacez l'événement onDragUpdate.

Enfin, le composant Bat doit répondre aux commandes du clavier. La fonction moveBy permet à un autre code d'indiquer à cette chauve-souris de se déplacer vers la gauche ou vers la droite d'un certain nombre de pixels virtuels. Cette fonction introduit une nouvelle fonctionnalité du moteur de jeu Flame: les Effect. En ajoutant l'objet MoveToEffect en tant qu'enfant de ce composant, le joueur voit la batte animée vers une nouvelle position. Une collection d'Effect est disponible dans Flame pour créer différents effets.

Les arguments du constructeur de l'effet incluent une référence au getter game. C'est pourquoi vous devez inclure le mixin HasGameReference dans cette classe. Ce mixin ajoute un accesseur game sécurisé à ce composant pour accéder à l'instance BrickBreaker en haut de l'arborescence des composants.

  1. Pour rendre le Bat disponible pour BrickBreaker, mettez à jour le fichier lib/src/components/components.dart comme suit.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

Ajouter la chauve-souris au monde

Pour ajouter le composant Bat au monde du jeu, mettez à jour BrickBreaker comme suit.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(                                                  // Add from here...
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );                                                          // To here.

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here.
}

L'ajout du mixin KeyboardEvents et de la méthode onKeyEvent ignorée gèrent la saisie au clavier. Rappelez-vous le code que vous avez ajouté précédemment pour déplacer la chauve-souris de la quantité d'étapes appropriée.

Le reste du code ajouté ajoute la chauve-souris au monde du jeu à la position et aux proportions appropriées. L'exposition de tous ces paramètres dans ce fichier vous permet de modifier facilement la taille relative de la batte et de la balle pour obtenir le ressenti souhaité dans le jeu.

Si vous jouez au jeu à ce stade, vous pouvez déplacer la batte pour intercepter la balle, mais vous ne recevez aucune réponse visible, à l'exception de la journalisation de débogage que vous avez laissée dans le code de détection des collisions de Ball.

Il est temps de corriger cela. Modifiez le composant Ball comme suit.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));                         // Modify from here...
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Ces modifications de code corrigent deux problèmes distincts.

Tout d'abord, il corrige le problème de disparition de la balle au moment où elle touche le bas de l'écran. Pour résoudre ce problème, remplacez l'appel removeFromParent par RemoveEffect. RemoveEffect supprime la balle du monde du jeu après l'avoir laissé quitter la zone de jeu visible.

Deuxièmement, ces modifications corrigent la gestion des collisions entre la batte et la balle. Ce code de gestion est très favorable au joueur. Tant que le joueur touche la balle avec la batte, elle revient en haut de l'écran. Si vous trouvez que cette gestion est trop indulgente et que vous souhaitez quelque chose de plus réaliste, modifiez-la pour qu'elle corresponde mieux à l'expérience que vous souhaitez proposer dans votre jeu.

Il est utile de souligner la complexité de la mise à jour de velocity. Il ne se contente pas d'inverser la composante y de la vitesse, comme cela a été fait pour les collisions avec les murs. Il met également à jour le composant x en fonction de la position relative de la batte et de la balle au moment du contact. Cela permet au joueur de mieux contrôler le comportement de la balle, mais la façon exacte dont cela se produit n'est pas communiquée au joueur, sauf par le biais du jeu.

Maintenant que vous avez une batte pour frapper la balle, il serait bien d'avoir des briques à casser avec la balle.

8. Briser la barrière

Créer les briques

Pour ajouter des briques au jeu :

  1. Insérez des constantes dans le fichier lib/src/config.dart comme suit.

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. Insérez le composant Brick comme suit.

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

À ce stade, la plupart de ce code devrait vous être familier. Ce code utilise un RectangleComponent, avec à la fois une détection de collision et une référence sécurisée par type au jeu BrickBreaker en haut de l'arborescence des composants.

Le nouveau concept le plus important introduit par ce code est la façon dont le joueur atteint la condition de victoire. La vérification de la condition de victoire interroge le monde pour trouver des briques et confirme qu'il n'en reste qu'une. Cela peut être un peu déroutant, car la ligne précédente supprime cette brique de son parent.

Il est important de comprendre que la suppression de composants est une commande mise en file d'attente. Il supprime la brique après l'exécution de ce code, mais avant le prochain "tic" du monde du jeu.

Pour rendre le composant Brick accessible à BrickBreaker, modifiez lib/src/components/components.dart comme suit.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

Ajouter des briques au monde

Mettez à jour le composant Ball comme suit.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

Il s'agit du seul nouvel aspect, un modificateur de difficulté qui augmente la vitesse de la balle après chaque collision avec une brique. Ce paramètre ajustable doit être testé pour trouver la courbe de difficulté adaptée à votre jeu.

Modifiez le jeu BrickBreaker comme suit.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

Si vous exécutez le jeu tel qu'il est actuellement, toutes les principales mécaniques de jeu s'affichent. Vous pourriez désactiver le débogage et considérer que c'est terminé, mais vous avez le sentiment qu'il manque quelque chose.

Capture d&#39;écran montrant brick_breaker avec la balle, la batte et la plupart des briques sur la zone de jeu. Chaque composant comporte des libellés de débogage

Que diriez-vous d'un écran de bienvenue, d'un écran de fin de partie et d'un score ? Flutter peut ajouter ces fonctionnalités au jeu, et c'est ce sur quoi vous allez vous concentrer ensuite.

9. Gagner le match

Ajouter des états de lecture

À cette étape, vous allez intégrer le jeu Flame dans un wrapper Flutter, puis ajouter des superpositions Flutter pour les écrans de bienvenue, de fin de partie et de victoire.

Commencez par modifier les fichiers de jeu et de composant pour implémenter un état de jeu qui indique si une superposition doit être affichée et, le cas échéant, laquelle.

  1. Modifiez le jeu BrickBreaker comme suit.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

Ce code modifie une grande partie du jeu BrickBreaker. L'ajout de l'énumération playState demande beaucoup de travail. Cela indique à quel stade le joueur se trouve dans le jeu (il commence à jouer, il joue, il perd ou il gagne). En haut du fichier, vous définissez l'énumération, puis l'instanciez en tant qu'état masqué avec des getters et des setters correspondants. Ces getters et setters permettent de modifier les superpositions lorsque les différentes parties du jeu déclenchent des transitions d'état de jeu.

Ensuite, vous devez diviser le code de onLoad en onLoad et en une nouvelle méthode startGame. Avant ce changement, vous ne pouviez démarrer une nouvelle partie qu'en redémarrant le jeu. Grâce à ces nouvelles fonctionnalités, le joueur peut désormais démarrer une nouvelle partie sans avoir à prendre des mesures aussi drastiques.

Pour permettre au joueur de démarrer une nouvelle partie, vous avez configuré deux nouveaux gestionnaires pour le jeu. Vous avez ajouté un gestionnaire de pression et étendu le gestionnaire de clavier pour permettre à l'utilisateur de démarrer une nouvelle partie de plusieurs manières. Une fois l'état de jeu modélisé, il est logique de mettre à jour les composants pour déclencher des transitions d'état de jeu lorsque le joueur gagne ou perd.

  1. Modifiez le composant Ball comme suit.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            },
          ),
        );                                                      // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

Cette petite modification ajoute un rappel onComplete à RemoveEffect, qui déclenche l'état de lecture gameOver. Ce paramètre devrait être correct si le joueur laisse la balle sortir du bas de l'écran.

  1. Modifiez le composant Brick comme suit.

lib/src/components/brick.dart

impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

En revanche, si le joueur parvient à détruire toutes les briques, l'écran "Victoire" s'affiche. Bravo, joueur !

Ajouter le wrapper Flutter

Pour fournir un emplacement où intégrer le jeu et ajouter des superpositions d'état de jeu, ajoutez le shell Flutter.

  1. Créez un répertoire widgets sous lib/src.
  2. Ajoutez un fichier game_app.dart et insérez-y le contenu suivant.

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                          child: Text(
                            'TAP TO PLAY',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.gameOver.name: (context, game) => Center(
                          child: Text(
                            'G A M E   O V E R',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.won.name: (context, game) => Center(
                          child: Text(
                            'Y O U   W O N ! ! !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

La plupart du contenu de ce fichier suit une compilation d'arborescence de widgets Flutter standard. Les parties spécifiques à Flame incluent l'utilisation de GameWidget.controlled pour créer et gérer l'instance de jeu BrickBreaker et le nouvel argument overlayBuilderMap pour GameWidget.

Les clés de cet élément overlayBuilderMap doivent correspondre aux superpositions ajoutées ou supprimées par le setter playState dans BrickBreaker. Si vous essayez de définir une superposition qui ne figure pas sur cette carte, vous risquez de voir des visages malheureux.

  1. Pour afficher cette nouvelle fonctionnalité à l'écran, remplacez le fichier lib/main.dart par le contenu suivant.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

Si vous exécutez ce code sur iOS, Linux, Windows ou le Web, le résultat prévu s'affiche dans le jeu. Si vous ciblez macOS ou Android, vous devez effectuer une dernière modification pour permettre l'affichage de google_fonts.

Activer l'accès aux polices

Ajouter une autorisation Internet pour Android

Pour Android, vous devez ajouter l'autorisation d'accès à Internet. Modifiez votre AndroidManifest.xml comme suit.

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

Modifier les fichiers d'autorisation pour macOS

Pour macOS, vous devez modifier deux fichiers.

  1. Modifiez le fichier DebugProfile.entitlements pour qu'il corresponde au code suivant.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Modifiez le fichier Release.entitlements pour qu'il corresponde au code suivant :

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

Si vous exécutez ce code tel quel, un écran d'accueil et un écran de fin de partie ou de victoire devraient s'afficher sur toutes les plates-formes. Ces écrans sont peut-être un peu simplistes. Il serait bien d'avoir un score. Alors, devinez ce que vous allez faire à l'étape suivante !

10. Suivre les scores

Ajouter un score au jeu

Au cours de cette étape, vous exposez le score du jeu au contexte Flutter environnant. Au cours de cette étape, vous exposez l'état du jeu Flame à la gestion de l'état Flutter environnante. Cela permet au code du jeu de mettre à jour le score chaque fois que le joueur casse une brique.

  1. Modifiez le jeu BrickBreaker comme suit.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

En ajoutant score au jeu, vous associez l'état du jeu à la gestion de l'état Flutter.

  1. Modifiez la classe Brick pour ajouter un point au score lorsque le joueur détruit des briques.

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Créer un jeu attrayant

Maintenant que vous pouvez suivre le score dans Flutter, il est temps de rassembler les widgets pour qu'ils soient esthétiques.

  1. Créez score_card.dart dans lib/src/widgets et ajoutez les éléments suivants.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({super.key, required this.score});

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Créez overlay_screen.dart dans lib/src/widgets et ajoutez le code suivant.

Cela permet d'améliorer les superpositions à l'aide de la puissance du package flutter_animate pour ajouter du mouvement et du style aux écrans superposés.

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({super.key, required this.title, required this.subtitle});

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

Pour en savoir plus sur les capacités de flutter_animate, consultez l'atelier de programmation Créer des UI de nouvelle génération dans Flutter.

Ce code a beaucoup changé dans le composant GameApp. Tout d'abord, pour permettre à ScoreCard d'accéder à score , vous devez convertir StatelessWidget en StatefulWidget. L'ajout du tableau de données nécessite l'ajout d'un Column pour empiler le score au-dessus du jeu.

Deuxièmement, pour améliorer les expériences de bienvenue, de fin de partie et de victoire, vous avez ajouté le nouveau widget OverlayScreen.

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Vous devriez maintenant pouvoir exécuter ce jeu sur l'une des six plates-formes cibles Flutter. Le jeu doit ressembler à ceci :

Capture d&#39;écran de brick_breaker montrant l&#39;écran de pré-jeu invitant l&#39;utilisateur à appuyer sur l&#39;écran pour jouer

Capture d&#39;écran de brick_breaker montrant l&#39;écran de fin de partie superposé à une batte et à certaines des briques

11. Félicitations

Félicitations, vous avez réussi à créer un jeu avec Flutter et Flame.

Vous avez créé un jeu à l'aide du moteur de jeu 2D Flame et l'avez intégré dans un wrapper Flutter. Vous avez utilisé les effets de Flame pour animer et supprimer des composants. Vous avez utilisé les packages Google Fonts et Flutter Animate pour donner une apparence soignée à l'ensemble du jeu.

Étape suivante

Découvrez quelques-uns des ateliers de programmation...

Documentation complémentaire