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 découvrirez comment intégrer Flame au système de gestion de l'état de Flutter.
Une fois terminé, votre jeu devrait ressembler à ce GIF animé, mais un peu plus lentement.
Points abordés
- Découvrez les bases de Flame, en commençant par
GameWidget
. - Utiliser une boucle de jeu
- Fonctionnement des
Component
de Flame. Ils sont semblables auxWidget
de Flutter. - Gérer les collisions
- Comment utiliser les
Effect
pour animer lesComponent
. - Comment superposer des
Widget
Flutter sur un jeu Flame. - Comment intégrer Flame à la gestion de l'état 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 en utilisant la boucle de jeu de Flame.
- Utilisez les fonctionnalités de Flutter, comme le package
google_fonts
etflutter_animate
, pour recréer l'atmosphère 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 vous alliez utiliser Visual Studio Code (VS Code) comme environnement de développement. VS Code est sans frais et fonctionne sur toutes les plates-formes principales. 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 faire X" au lieu de "effectuez l'action appropriée dans votre éditeur pour faire X".
Vous pouvez utiliser l'éditeur de votre choix, comme Android Studio, les IDE IntelliJ, Emacs, Vim ou Notepad++. Tous fonctionnent avec 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.
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 associez un appareil Android à votre ordinateur portable Windows à l'aide d'un câble USB. L'application en cours de développement s'exécute alors sur cet appareil Android ou dans un émulateur Android. Vous pouvez aussi choisir Windows comme cible de développement : l'application en cours de développement s'exécute comme une application Windows, en parallèle de l'éditeur.
Faites votre choix avant de continuer. Vous pourrez toujours exécuter votre application sur d'autres systèmes d'exploitation par la suite. Définir 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, 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 :
- SDK Flutter
- Visual Studio Code avec plug-in Flutter
- Logiciel de compilation pour la cible de développement choisie. (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 des problèmes, vous trouverez peut-être certaines des questions-réponses ci-dessous (sur StackOverflow) utiles à des fins de dépannage.
Questions fréquentes
- Comment trouver le chemin d'accès au SDK Flutter ?
- Que faire lorsqu'une commande Flutter est introuvable ?
- Comment résoudre le problème "Waiting for another Flutter command to release the startup lock" (En attente d'une autre commande Flutter pour débloquer le verrouillage au démarrage) ?
- Comment indiquer à Flutter où se trouve le répertoire d'installation du SDK Android ?
- Comment résoudre l'erreur Java lors de l'exécution de
flutter doctor --android-licenses
? - Que faire lorsque l'outil
sdkmanager
d'Android est introuvable ? - Comment résoudre l'erreur "
cmdline-tools
component is missing" (Le composantcmdline-tools
est manquant) ? - Comment exécuter CocoaPods sur Apple Silicon (M1) ?
- Comment désactiver le formatage automatique à l'enregistrement dans VS Code ?
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 le répertoire de votre choix.
- Lancez Visual Studio Code.
- Ouvrez la palette de commandes (
F1
,Ctrl+Shift+P
ouShift+Cmd+P
), puis saisissez "flutter new". Lorsqu'elle s'affiche, sélectionnez la commande Flutter: New Project (Flutter : nouveau projet).
- 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 de privilèges élevés et dont le chemin ne contient pas d'espace. Par exemple, votre répertoire d'accueil ou
C:\src\
.
- Nommez votre projet
brick_breaker
. Le reste de cet atelier de programmation suppose que vous avez nommé votre applicationbrick_breaker
.
Flutter crée le dossier de votre projet et VS Code l'ouvre. Vous allez maintenant remplacer le contenu des deux fichiers et créer la structure de base de l'application.
Copier et coller l'application d'origine
Cela ajoute l'exemple de code fourni dans cet atelier de programmation à votre application.
- Dans le volet de gauche de VS Code, cliquez sur Explorer (Explorateur) et ouvrez le fichier
pubspec.yaml
.
- 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.
- Ouvrez le fichier
main.dart
dans le répertoirelib/
.
- 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));
}
- Exécutez ce code pour vérifier que tout fonctionne. Une nouvelle fenêtre avec un arrière-plan noir et vide devrait s'afficher. Le pire jeu vidéo du monde s'affiche désormais à 60 FPS !
4. Créer le jeu
Évaluer le jeu
Un jeu en deux dimensions (2D) a besoin d'une zone de jeu. Vous allez construire une zone de dimensions spécifiques, puis utiliser ces dimensions pour dimensionner d'autres aspects du jeu.
Il existe différentes 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 original a été créé, la convention était de définir l'origine en haut à gauche. La direction x positive est restée la même, mais y a été inversée. La direction positive de l'axe x était à droite et celle de l'axe y était 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 contiendra d'autres constantes lors 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 mise à l'échelle pour s'adapter à la fenêtre dans laquelle elle est affichée, mais tous les composants ajoutés à l'écran sont conformes à cette hauteur et à cette largeur.
Créer une zone de jeu
Dans le jeu Breakout, la balle rebondit sur les murs de la zone de jeu. Pour gérer les collisions, vous avez d'abord besoin d'un composant PlayArea
.
- Créez un fichier nommé
play_area.dart
dans un nouveau répertoire nommélib/src/components
. - 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 a des Widget
, Flame a 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à que réside 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 mutable. 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.
- Pour éviter l'encombrement, ajoutez un fichier contenant tous les composants de ce projet. Créez un fichier
components.dart
danslib/src/components
et ajoutez le contenu suivant.
lib/src/components/components.dart
export 'play_area.dart';
L'instruction 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 contiendra de plus en plus d'entrées à mesure que vous ajouterez des composants lors des étapes suivantes.
Créer un jeu Flame
Pour supprimer les lignes ondulées rouges de l'étape précédente, dérivez une nouvelle sous-classe pour FlameGame
de Flame.
- Créez un fichier nommé
brick_breaker.dart
danslib/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 est redimensionné pour remplir l'écran qui le contient et ajoute des bandes noires 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.
- Configure le coin 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)
. - Ajoute
PlayArea
àworld
. Le monde représente le monde du jeu. Il projette tous ses enfants à travers la transformation de la vueCameraComponent
.
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 à la figure ci-dessous.
À 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.
- 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 reviendra à plusieurs reprises dans cet atelier de programmation. Cela vous permet de modifier les gameWidth
et gameHeight
de niveau supérieur pour explorer l'évolution de l'apparence et de l'expérience de jeu.
- Créez le composant
Ball
dans un fichier nomméball.dart
danslib/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 défini PlayArea
à l'aide de RectangleComponent
, il est donc logique qu'il existe d'autres formes. CircleComponent
, comme RectangleComponent
, dérive de PositionedComponent
, ce qui vous permet de positionner la balle sur 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 la vitesse correspond à la fois à la rapidité 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 la frame précédente et celle-ci. 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.
Portez une attention particulière à la mise à jour de position += velocity * dt
. Voici comment implémenter la mise à jour d'une simulation discrète du mouvement au fil du temps.
- Pour inclure le composant
Ball
dans la liste des composants, modifiez le fichierlib/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 une balle. Placez-le dans le monde et configurez-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
de la balle au centre de la zone d'affichage, le code divise d'abord la taille du jeu par deux, car Vector2
possède des surcharges d'opérateur (*
et /
) pour mettre à l'échelle un Vector2
par une valeur scalaire.
Définir le velocity
de la balle est plus complexe. L'objectif est de faire descendre la balle sur l'écran dans une direction aléatoire à une vitesse raisonnable. L'appel à la méthode normalized
crée un objet Vector2
défini sur la même direction que l'objet Vector2
d'origine, mais réduit à une distance de 1. La vitesse de la balle reste constante, quelle que soit sa direction. La vitesse de la balle est ensuite mise à l'échelle pour correspondre à un quart de la hauteur du jeu.
Pour obtenir les bonnes valeurs, il faut procéder à plusieurs itérations, également appelées tests en jeu dans le secteur.
La dernière ligne active l'affichage du débogage, qui ajoute des informations supplémentaires à l'affichage pour faciliter le débogage.
Lorsque vous exécutez le jeu, il doit ressembler à l'affichage suivant.
Les composants PlayArea
et Ball
contiennent tous deux des informations de débogage, mais les caches de l'arrière-plan recadrent les nombres de PlayArea
. La raison pour laquelle des informations de débogage s'affichent partout est que 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 est plus utile.
Si vous redémarrez votre jeu plusieurs fois, vous remarquerez peut-être que la balle n'interagit pas avec les murs comme prévu. Pour obtenir cet effet, vous devez ajouter la détection des collisions, ce que vous ferez à l'étape suivante.
6. Rebondir
Ajouter la détection de collision
La détection de collision ajoute un comportement dans lequel votre jeu reconnaît lorsque 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;
}
}
Il suit les masques de collision des composants et déclenche des rappels de collision à chaque tic du jeu.
Pour commencer à remplir les hitboxes du jeu, modifiez le composant PlayArea
comme indiqué :
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 du composant RectangleComponent
permet de créer une zone de sélection pour la détection des collisions qui correspond à la taille du composant parent. Il existe un constructeur d'usine pour RectangleHitbox
appelé relative
pour les cas où vous souhaitez une hitbox plus petite ou plus grande que le composant parent.
Fais 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 avez modifié 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 des collisions 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 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 cas où la balle entre en collision avec des éléments autres que la batte. Un petit 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 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 Flame.
Maintenant que la balle entre en collision avec les murs du jeu, il serait utile de donner au joueur une batte pour la frapper…
7. Frapper la balle avec la batte
Créer le fichier BAT
Pour ajouter une batte afin de maintenir la balle en jeu dans le jeu,
- 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 quelques explications. 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 chauve-souris à chaque appui sur la flèche vers la gauche ou vers la droite.
- 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 Bat
à l'écran. Pour ce faire, il remplace le rappel render
.
En examinant de près l'appel canvas.drawRRect
(dessiner un rectangle arrondi), vous vous demandez peut-être "où est le rectangle ?". Offset.zero & size.toSize()
utilise une surcharge operator &
sur la classe dart:ui
Offset
qui crée des Rect
. Cette abréviation peut vous dérouter au premier abord, mais vous la verrez fréquemment dans le code Flutter et Flame de niveau inférieur.
Deuxièmement, ce composant Bat
est déplaçable au doigt ou à la souris, selon 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 au contrôle du clavier. La fonction moveBy
permet à un autre code de demander à la 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 à une nouvelle position. Flame propose une collection de Effect
pour effectuer divers effets.
Les arguments du constructeur Effect 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
de type sécurisé à ce composant pour accéder à l'instance BrickBreaker
en haut de l'arborescence des composants.
- Pour rendre
Bat
disponible pourBrickBreaker
, mettez à jour le fichierlib/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
remplacée gèrent la saisie au clavier. Rappelez-vous du code que vous avez ajouté précédemment pour déplacer la chauve-souris de la quantité appropriée.
Le reste du code ajouté permet d'ajouter la chauve-souris au monde du jeu, à la bonne position et avec les bonnes proportions. Le fait que tous ces paramètres soient exposés dans ce fichier vous permet d'ajuster plus facilement la taille relative de la batte et de la balle pour obtenir le bon feeling pour le jeu.
Si vous jouez à ce stade, vous verrez que vous pouvez déplacer la batte pour intercepter la balle, mais vous n'obtiendrez 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
.
Corrigeons cela maintenant. 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 la balle qui disparaît dès qu'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ée sortir de la zone de jeu visible.
Ensuite, ces modifications corrigent la gestion des collisions entre la batte et la balle. Ce code de gestion est très avantageux pour le 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 mieux l'adapter à l'expérience que vous souhaitez offrir dans votre jeu.
Il convient de souligner la complexité de la mise à jour velocity
. Il ne fait pas que inverser la composante y
de la vitesse, comme c'était le cas pour les collisions avec les murs. Il met également à jour le composant x
d'une manière qui dépend 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 la façon exacte dont cela fonctionne n'est communiquée au joueur que 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. Démolir le mur
Créer les briques
Pour ajouter des briques au jeu :
- 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.
- 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 à la fois la détection des collisions et une référence de type sécurisé 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 remplit 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 sembler un peu déroutant, car la ligne précédente supprime cette brique de son parent.
Le point essentiel à comprendre est 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 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';
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é en jeu pour trouver la courbe de difficulté approprié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, toutes les mécaniques de jeu clés s'affichent. Vous pouvez désactiver le débogage et considérer que vous avez terminé, mais il manque quelque chose.
Que diriez-vous d'un écran d'accueil, d'un écran de fin de partie et peut-être d'un score ? Flutter peut ajouter ces fonctionnalités au jeu. C'est sur cela que vous allez vous concentrer ensuite.
9. Gagner la partie
Ajouter des états de lecture
Dans cette étape, vous allez intégrer le jeu Flame dans un wrapper Flutter, puis ajouter des calques Flutter pour les écrans d'accueil, de fin de partie et de victoire.
Commencez par modifier les fichiers de jeu et de composants pour implémenter un état de lecture qui indique s'il faut afficher une superposition et, le cas échéant, laquelle.
- 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 permet de savoir où se trouve le joueur dans le processus de saisie, de jeu, et de perte ou de gain du jeu. En haut du fichier, vous définissez l'énumération, puis vous l'instanciez en tant qu'état masqué avec des getters et des setters correspondants. Ces getters et setters permettent de modifier les calques lorsque les différentes parties du jeu déclenchent des transitions d'état de lecture.
Ensuite, vous allez diviser le code dans onLoad
en onLoad et une nouvelle méthode startGame
. Auparavant, vous ne pouviez démarrer une nouvelle partie qu'en redémarrant le jeu. Grâce à ces nouveautés, le joueur peut désormais commencer une nouvelle partie sans avoir recours à 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 d'événements tactiles et étendu le gestionnaire de clavier pour permettre à l'utilisateur de commencer une nouvelle partie de plusieurs manières. Maintenant que l'état de lecture est 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.
- 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 être à peu près correct si le joueur permet à la balle de sortir du bas de l'écran.
- 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, il gagne la partie et un écran "Partie gagnée" s'affiche. Bien joué !
Ajouter le wrapper Flutter
Pour fournir un emplacement où intégrer le jeu et ajouter des calques d'état de lecture, ajoutez le shell Flutter.
- Créez un répertoire
widgets
souslib/src
. - 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 structure de widget Flutter standard. Les parties spécifiques à Flame incluent l'utilisation de GameWidget.controlled
pour construire et gérer l'instance de jeu BrickBreaker
et le nouvel argument overlayBuilderMap
pour GameWidget
.
Les clés de ce overlayBuilderMap
doivent correspondre aux calques que le setter playState
dans BrickBreaker
a ajoutés ou supprimés. Si vous essayez de définir un calque qui ne figure pas dans cette carte, vous risquez de rencontrer des problèmes.
- 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 prévu s'affiche dans le jeu. Si vous ciblez macOS ou Android, vous devez effectuer une dernière modification pour que google_fonts
s'affiche.
Activer l'accès aux polices
Ajouter une autorisation Internet pour Android
Pour Android, vous devez ajouter l'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 devez modifier deux fichiers.
- 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>
- 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 le code tel quel, un écran d'accueil et un écran de fin de partie (perdue ou gagnée) devraient s'afficher sur toutes les plates-formes. Ces écrans sont peut-être un peu simplistes et il serait bien d'avoir un score. Devinez ce que vous allez faire à l'étape suivante !
10. Tenir le score
Ajouter un score au jeu
Au cours de cette étape, vous allez exposer le score du jeu au contexte Flutter environnant. Dans cette étape, vous allez exposer 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.
- 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.
- 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éer un jeu esthétique
Maintenant que vous pouvez tenir le score dans Flutter, il est temps d'assembler les widgets pour que le jeu soit esthétique.
- Créez
score_card.dart
danslib/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!,
),
);
},
);
}
}
- Créez
overlay_screen.dart
danslib/src/widgets
et ajoutez le code suivant.
Cela permet d'améliorer les superpositions en utilisant 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 permettre à ScoreCard
d'accéder à score
, vous le convertissez de 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 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.
),
),
),
),
),
);
}
}
Une fois tout cela en place, vous devriez pouvoir exécuter ce jeu sur l'une des six plates-formes cibles Flutter. Le jeu doit ressembler à ce qui suit.
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 que l'ensemble du jeu soit bien conçu.
Étape suivante
Découvrez quelques-uns des ateliers de programmation...
- Créer des interfaces utilisateur nouvelle génération dans Flutter
- Mettre en valeur son application Flutter
- Ajouter des achats via l'application à votre application Flutter