Présentation de Flame avec Flutter

1. Introduction

Flame est un moteur de jeu en 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 utiliserez les composants de Flame pour dessiner la batte, la balle et les briques. Vous utiliserez les effets de Flame pour animer le mouvement de la chauve-souris et apprendrez à intégrer Flame au système de gestion d'état de Flutter.

Une fois l'opération terminée, votre jeu devrait ressembler à ce GIF animé, mais un peu plus lent.

Enregistrement d'écran d'un jeu en cours Le match s'est considérablement accéléré.

Points abordés

  • Découvrez les principes de base de Flame, à commencer par GameWidget.
  • Utiliser une boucle de jeu
  • Fonctionnement des éléments Component de Flame Ils sont semblables aux Widget de Flutter.
  • Comment gérer les collisions.
  • Comment utiliser des éléments Effect pour animer des éléments Component
  • Superposition de fichiers Widget Flutter sur un jeu Flame
  • Intégrer Flame à la gestion des états Flutter

Ce que vous allez faire

Dans cet atelier de programmation, vous allez créer un jeu 2D à l'aide de Flutter et Flame. Une fois l'opération terminée, votre jeu doit remplir les conditions suivantes

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

2. Configurer votre environnement Flutter

Éditeur

Pour simplifier cet atelier de programmation, nous partons du principe que votre environnement de développement est Visual Studio Code (VS Code). VS Code est sans frais et fonctionne sur les principales plates-formes. Nous utilisons VS Code pour cet atelier de programmation, car les instructions par défaut renvoient vers des raccourcis spécifiques à VS Code. Les tâches deviennent plus simples : « cliquez sur ce bouton » ou "appuyez sur cette touche pour faire X" plutôt que d'effectuer l'action appropriée dans votre éditeur pour faire X.

Vous pouvez utiliser l'éditeur de votre choix: Android Studio, d'autres IDE IntelliJ, Emacs, Vim ou Notepad++. Elles fonctionnent toutes avec Flutter.

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

Choisir une cible de développement

Flutter produit des applications pour de nombreuses 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 connecté à l'ordinateur portable 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. Choisissez ensuite Android comme cible de développement. Pour prévisualiser votre application, connectez un appareil Android à votre ordinateur portable Windows à l'aide d'un câble USB. Votre application en développement s'exécute sur cet appareil Android connecté ou dans un émulateur Android. Vous auriez pu choisir Windows comme cible de développement, qui exécute votre application en cours de développement en tant qu'application Windows avec votre é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 rechargement à chaud avec état de Flutter. Pour le moment, Flutter ne peut pas effectuer de hot reload des applications Web.

Faites votre choix avant de continuer. Vous pourrez toujours exécuter votre application sur d'autres systèmes d'exploitation par la suite. Le choix d'une cible de développement rend l'étape suivante plus fluide.

Installer Flutter

Les instructions les plus récentes sur l'installation du SDK Flutter sont disponibles sur docs.flutter.dev.

Les instructions du site Web Flutter couvrent l'installation du SDK, 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 choisie. (Vous avez besoin de Visual Studio pour cibler Windows ou Xcode pour macOS ou iOS.)

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

Si vous avez besoin de résoudre des problèmes, certaines des questions et réponses ci-dessous (sur StackOverflow) peuvent vous être utiles.

Questions fréquentes

3. Créer un projet

Créer votre premier projet Flutter

Pour cela, vous devez ouvrir VS Code et créer le modèle d'application Flutter dans le 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". Lorsqu'il apparaît, 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 de tout répertoire ne nécessitant pas de privilèges élevés ou dont le chemin d'accès contient un espace. Il peut s'agir, par exemple, de votre répertoire d'accueil ou de C:\src\.

Capture d'écran de VS Code avec une application vide affichée comme sélectionnée dans le nouveau parcours d'application

  1. Nommez votre projet brick_breaker. La suite de cet atelier de programmation suppose 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 alors ajouté à votre application.

  1. Dans le volet 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 indiquant 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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

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&#39;écran partielle de VS Code avec une flèche indiquant l&#39;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 correctement. Une nouvelle fenêtre ne doit s'afficher qu'avec un arrière-plan noir vide. Le pire des jeux vidéo au monde effectue désormais un rendu à 60 FPS !

Capture d&#39;écran montrant la fenêtre de l&#39;application brique_breaker entièrement noire.

4. Créer le jeu

Évaluer le jeu

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

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

La convention d'origine du jeu Breakout initial consistait à définir l'origine dans le coin supérieur gauche. La direction X positive est restée la même, mais la direction Y a été inversée. La direction x positive était dans la bonne direction et la direction y était descendante. Pour rester fidèle à l'époque, ce jeu définit l'origine dans l'angle supérieur gauche.

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

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Ce jeu mesure 820 pixels de large et 1 600 pixels de haut. La zone de jeu s'ajuste à la fenêtre dans laquelle elle est affichée, mais tous les composants ajoutés à l'écran se conforment à cette hauteur et cette largeur.

Créer une PlayArea

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

  1. Créez un fichier nommé play_area.dart dans un nouveau répertoire intitulé lib/src/components.
  2. Ajoutez le code suivant à 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);
  }
}

Alors que Flutter offre des éléments Widget, Flame propose des éléments Component. Les applications Flutter consistent à créer des arborescences de widgets, tandis que les jeux Flame consistent à gérer des arborescences de composants.

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

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

  1. Pour éviter le désordre, 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 la fonctionnalité que ce fichier expose lorsqu'il est importé dans un autre fichier. Ce fichier contiendra plus d'entrées à mesure que vous ajouterez de nouveaux composants dans les étapes suivantes.

Créer un jeu Flame

Pour éteindre les ondulations rouges de l'étape précédente, dérivez une nouvelle sous-classe pour le 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 construction de l'instance de jeu, ce code configure le jeu pour utiliser le rendu à résolution fixe. Le jeu est redimensionné pour remplir l'écran qui le contient et ajoute le format letterbox si nécessaire.

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

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

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

Jouez à 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 la fenêtre de l&#39;application &quot;brique_breaker&quot; avec un rectangle 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. Montrez le ballon

Créer le composant "balle"

Pour placer une balle en mouvement sur 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

Dans cet atelier de programmation, le modèle de conception consistant à définir des constantes nommées en tant que valeurs dérivées est renvoyé à de nombreuses reprises. Cela vous permet de modifier les gameWidth et gameHeight de premier niveau pour découvrir comment l'apparence du jeu évolue 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;
  }
}

Plus tôt, vous avez défini PlayArea à l'aide de RectangleComponent. Il est donc logique qu'il existe davantage de formes. CircleComponent, comme RectangleComponent, est dérivé de PositionedComponent. Vous pouvez donc positionner la balle sur l'écran. Plus important encore, sa position peut être modifiée.

Ce composant introduit le concept de velocity, ou un changement de position au fil du temps. La vitesse est un objet Vector2, car la vitesse correspond à 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 l'image précédente et cette image. Cela vous permet de 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.

Examinez attentivement la mise à jour position += velocity * dt. C'est ainsi que vous mettez en œuvre la mise à jour d'une simulation discrète de 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';

Mettre le ballon au monde

Tu as une balle. Mettons-le dans le monde et configurons-le pour qu'il se déplace dans l'aire 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 le position du ballon au centre de la zone d'affichage, le code divise d'abord la taille du jeu, car Vector2 comporte des surcharges d'opérateurs (* et /) pour mettre à l'échelle une Vector2 selon une valeur scalaire.

Placer le velocity de la balle implique plus de complexité. 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'élément Vector2 d'origine, mais réduit à une distance de 1. Cela permet de maintenir la vitesse de la balle constante, quelle que soit sa direction. La vitesse de la balle est ensuite ajustée pour correspondre au quart de la hauteur du jeu.

Pour bien obtenir ces différentes valeurs, il faut effectuer des itérations, ce que l'on appelle également des tests de jeu dans le secteur.

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

Lorsque vous exécutez le jeu, il doit ressembler à l'écran suivant.

Capture d&#39;écran d&#39;une fenêtre de l&#39;application &quot;brique_breaker&quot; avec un cercle bleu au-dessus d&#39;un rectangle sable. Le cercle bleu est annoté avec des chiffres indiquant sa taille et sa position à l&#39;écran.

Les composants PlayArea et Ball contiennent tous deux des informations de débogage, mais les caches d'arrière-plan recadrent les numéros de PlayArea. Des informations de débogage sont affichées, car vous avez activé debugMode pour l'ensemble de l'arborescence des composants. Vous pouvez également activer le débogage uniquement pour les composants sélectionnés, si cela vous est plus utile.

Si vous redémarrez le jeu plusieurs fois, vous remarquerez peut-être que la balle n'interagit pas comme prévu avec les murs. Pour obtenir ce résultat, vous devez ajouter la 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 détecter lorsque deux objets sont en contact l'un avec l'autre.

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 masques de collision des composants et de déclencher des rappels de collision à chaque tick de jeu.

Pour commencer à remplir les masques de collision 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);
  }
}

L'ajout d'un composant RectangleHitbox en tant qu'enfant de RectangleComponent crée une zone de positionnement correspondant à la taille du composant parent pour la détection de collision. Il existe un constructeur de fabrique pour RectangleHitbox appelé relative pour les cas où vous souhaitez un masque de collision plus petit ou plus grand que le composant parent.

Lancer la balle

Jusqu'à présent, l'ajout de la détection de collision n'a eu aucune incidence sur le gameplay. Elle change une fois que vous avez modifié le composant Ball. C'est le comportement de la balle qui doit changer lorsqu'elle touche la 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 une modification majeure 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 est entré en collision avec PlayArea. Cela semble redondant pour le moment, car il n'y a pas d'autres composants dans l'univers du jeu. Cela changera à l'étape suivante, lorsque vous ajouterez une chauve-souris au monde. Ensuite, il ajoute également une condition else à gérer en cas de collision de la balle avec des éléments qui ne sont pas des chauves-souris. Petit rappel : implémentez la logique restante, si vous le souhaitez.

Lorsque la balle touche la paroi inférieure, elle disparaît simplement de la surface de jeu tout en restant bien visible. Vous gérerez cet artefact lors d'une prochaine étape, en utilisant la puissance des effets de flamme.

Maintenant que la balle touche les murs du jeu, il serait utile de donner au joueur une batte pour la frapper...

7. Obtenir la batte sur la balle

Créer la batte

Pour ajouter une batte et 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. En revanche, la constante batStep a besoin d'une touche d'explication. Pour interagir avec la balle dans ce jeu, le joueur peut faire glisser la batte avec la souris ou le doigt, selon la plate-forme, ou utiliser le clavier. La constante batStep configure la distance parcourue par la batte à chaque appui sur les flèches vers la gauche ou vers la droite.

  1. Définissez la classe du 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 Bat à l'écran. Pour ce faire, elle remplace le rappel render.

Si vous examinez attentivement l'appel canvas.drawRRect (dessin d'un rectangle arrondi), vous pouvez vous demander "où est le rectangle ?". Offset.zero & size.toSize() exploite une surcharge operator & sur la classe Offset dart:ui qui crée des Rect. Ce raccourci peut vous déconcerter au début, mais vous le verrez souvent dans le code Flutter et Flame de niveau inférieur.

Deuxièmement, vous pouvez faire glisser ce composant Bat à l'aide du doigt ou de la souris selon la plate-forme. Pour implémenter cette fonctionnalité, vous devez ajouter le mixin DragCallbacks et ignorer 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 batte de se déplacer vers la gauche ou vers la droite d'un certain nombre de pixels virtuels. Cette fonction introduit une nouvelle fonctionnalité pour le moteur de jeu Flame: les Effects. En ajoutant l'objet MoveToEffect en tant qu'enfant de ce composant, le joueur voit la batte animée à une nouvelle position. Il existe un ensemble de Effects disponibles dans Flame pour effectuer divers effets.

Les arguments du constructeur de l'effet incluent une référence au getter game. C'est pourquoi vous incluez le mixin HasGameReference dans cette classe. Ce mixin ajoute un accesseur game avec sûreté du typage à 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';

Ajoutez la chauve-souris au monde

Pour ajouter le composant Bat à l'univers 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(Bat(                                              // Add from here...
        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 la méthode onKeyEvent remplacée gèrent la saisie au clavier. Rappelez-vous le code que vous avez ajouté précédemment pour déplacer la batte en fonction du nombre de pas approprié.

Le reste du code ajouté ajoute la batte au monde du jeu dans la position et les proportions appropriées. L'affichage de tous ces paramètres dans ce fichier vous permet d'ajuster la taille relative de la batte et de la balle pour obtenir les sensations du jeu.

Si vous jouez à ce stade, vous constatez que vous pouvez déplacer la batte pour intercepter la balle, mais que vous n'obtenez aucune réponse visible, à l'exception de la journalisation de débogage que vous avez laissée dans le code de détection de collision de Ball.

Il est temps de résoudre ce problème. 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(                                       // Modify from here...
          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 {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Ces modifications de code corrigent deux problèmes distincts.

Tout d'abord, elle corrige la balle qui ressort dès qu'elle touche le bas de l'écran. Pour résoudre ce problème, remplacez l'appel removeFromParent par RemoveEffect. RemoveEffect retire le ballon du monde du jeu après l'avoir laissé quitter l'espace de jeu visible.

Deuxièmement, ces modifications corrigent la gestion des collisions entre la batte et la balle. Ce code de traitement joue en grande partie en faveur du joueur. Tant que le joueur touche la balle avec la batte, la balle revient en haut de l'écran. Si cela vous semble trop indulgent et que vous voulez quelque chose de plus réaliste, modifiez cette gestion pour qu'elle corresponde mieux à ce que vous voulez que votre jeu ressente.

Il est intéressant de souligner la complexité de la mise à jour de velocity. Cette opération n'inverse pas seulement la composante y de la vitesse, comme cela a été fait pour les collisions de parois. 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 donne au joueur plus de contrôle sur ce que fait la balle, mais exactement comment elle n'est communiquée au joueur que par le jeu.

Maintenant que vous avez une batte pour frapper la balle, ce serait bien d'avoir des briques pour la casser !

8. Faites tomber le mur

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>());
    }
  }
}

À présent, la majeure partie de ce code devrait vous être familière. Ce code utilise un RectangleComponent, avec une détection de collision et une référence de sûreté du typage au jeu BrickBreaker en haut de l'arborescence des composants.

Le nouveau concept le plus important que ce code introduit est la façon dont le joueur atteint la condition de victoire. La vérification des conditions de victoire interroge le monde à la recherche de 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 essentiel de comprendre que la suppression de composants est une commande en file d'attente. Il supprime la brique après l'exécution de ce code, mais avant le prochain tick 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';

Ajoutez 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. Ce paramètre réglable 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 se trouve actuellement, tous les mécanismes clés du jeu s'affichent. Vous pouvez désactiver le débogage et le marquer comme terminé, mais vous avez l'impression qu'il manque quelque chose.

Capture d&#39;écran montrant un bris de briques avec une balle, une batte et la plupart des briques de l&#39;aire de jeu. Chacun des composants possède des étiquettes de débogage

Que diriez-vous d'un écran d'accueil, d'une partie hors écran et peut-être d'un score ? Flutter peut ajouter ces fonctionnalités au jeu, et c'est là que vous allez maintenant vous concentrer.

9. Gagner la partie

Ajouter des états de lecture

Au cours de cette étape, vous allez intégrer le jeu Flame dans un wrapper Flutter, puis ajouter des superpositions Flutter pour les écrans d'accueil, de partie terminée et de victoires.

Tout d'abord, vous devez modifier les fichiers du jeu et des composants pour implémenter un état de lecture qui indique si une superposition doit être affichée, et si oui, 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 en grande partie le jeu BrickBreaker. L'ajout de l'énumération playState demande beaucoup de travail. Ces métriques permettent de savoir où se trouve le joueur lors de l'entrée, de la partie, et de la partie perdante ou de la victoire. En haut du fichier, vous définissez l'énumération, puis l'instanciez en tant qu'état caché 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.

Vous allez ensuite diviser le code de onLoad en onLoad et une nouvelle méthode startGame. Avant ce changement, vous ne pouviez lancer une nouvelle partie qu'en redémarrant le jeu. Grâce à ces nouveautés, le joueur peut désormais commencer une nouvelle partie sans prendre des mesures aussi drastiques.

Pour permettre au joueur de commencer une nouvelle partie, vous avez configuré deux nouveaux gestionnaires pour le jeu. Vous avez ajouté un gestionnaire d'appui et étendu le gestionnaire de clavier pour permettre à l'utilisateur de démarrer une nouvelle partie dans plusieurs modalités. Une fois l'état de lecture modélisé, il serait judicieux de mettre à jour les composants pour déclencher des transitions d'état de lecture 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. Cela devrait sembler correct si le joueur autorise la balle à s'échapper du bas de l'écran.

  1. Modifiez 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.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 peut casser toutes les briques, il a gagné une "partie gagnée". l'écran. Félicitations, bravo !

Ajouter le wrapper Flutter

Pour fournir un emplacement permettant d'intégrer le jeu et d'ajouter des superpositions d'état de lecture, 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(
        useMaterial3: true,
        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 arborescence de widgets Flutter standard. Les parties spécifiques à Flame incluent l'utilisation de GameWidget.controlled pour construire et gérer l'instance de jeu BrickBreaker, ainsi que le nouvel argument overlayBuilderMap pour GameWidget.

Les clés de ce overlayBuilderMap doivent s'aligner sur les superpositions ajoutées ou supprimées par le setter playState dans BrickBreaker. Si vous tentez de définir une superposition qui ne figure pas sur cette carte, cela entraînera des visages mécontents dans tout le monde.

  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 sur le Web, le résultat souhaité s'affiche dans le jeu. Si vous ciblez macOS ou Android, vous devez effectuer un dernier ajustement pour activer l'affichage de google_fonts.

Activer l'accès aux polices

Ajouter une autorisation Internet pour Android

Pour Android, vous devez ajouter une autorisation 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 de droits d'accès pour macOS

Pour macOS, vous avez deux fichiers à modifier.

  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>

L'exécution telle quelle devrait afficher un écran de bienvenue et une fin de partie ou un écran gagné sur toutes les plates-formes. Ces écrans peuvent être un peu simplistes, ce serait bien d'avoir un score. Alors, devinez ce que vous ferez à l'étape suivante !

10. Garder le score

Ajouter le score au match

Dans cette étape, vous allez exposer le score du jeu au contexte Flutter environnant. Au cours de cette étape, vous allez exposer l'état du jeu Flame à la gestion des états Flutter environnant. Le code du jeu peut ainsi 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 liez l'état du jeu à la gestion de l'état Flutter.

  1. Modifiez la classe Brick pour ajouter un point au score lorsque le joueur casse 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éez un beau jeu

Maintenant que vous pouvez conserver des scores dans Flutter, il est temps de mettre en place les widgets pour que tout s'affiche correctement.

  1. Créez score_card.dart dans lib/src/widgets et ajoutez ce qui suit.

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'affiner les superpositions en exploitant la puissance du package flutter_animate pour ajouter du mouvement et du style aux écrans de superposition.

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 la puissance de flutter_animate, consultez l'atelier de programmation Créer des interfaces utilisateur de nouvelle génération dans Flutter.

Ce code a beaucoup changé dans le composant GameApp. Tout d'abord, pour autoriser ScoreCard à accéder à score , vous devez convertir l'objet 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.

Ensuite, pour améliorer l'accueil, les parties et les expériences gagnées, 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(
        useMaterial3: true,
        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.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Maintenant que tout est en place, vous devriez pouvoir exécuter ce jeu sur l'une des six plates-formes cibles Flutter. Le jeu doit se présenter comme suit.

Capture d&#39;écran de &quot;brique_breaker&quot; montrant l&#39;écran d&#39;avant-match invitant l&#39;utilisateur à appuyer sur l&#39;écran pour jouer au jeu

Capture d&#39;écran de &quot;brique_breaker&quot; montrant le jeu à l&#39;écran, avec la superposition d&#39;une batte et de certaines 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 Flame 2D 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é Google Fonts et les packages Flutter Animate pour améliorer la conception du jeu.

Étape suivante

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

Documentation complémentaire