Créez un jeu de physique 2D avec Flutter et Flame

Créer un jeu de physique 2D avec Flutter et Flame

À propos de cet atelier de programmation

subjectDernière mise à jour : juin 23, 2025
account_circleRédigé par Brett Morgan

1. Avant de commencer

Flame est un moteur de jeu 2D basé sur Flutter. Dans cet atelier de programmation, vous allez créer un jeu qui utilise une simulation physique 2D semblable à Box2D, appelée Forge2D. Vous utilisez les composants de Flame pour peindre la réalité physique simulée à l'écran afin que vos utilisateurs puissent jouer avec. Une fois terminé, votre jeu devrait ressembler à ce GIF animé:

Animation du jeu avec ce jeu de physique 2D

  • Fonctionnement de base de Forge2D, en commençant par les différents types de corps physiques.
  • Configurer une simulation physique en 2D

Ce dont vous avez besoin

Logiciel de compilation pour la cible de développement de votre choix. Cet atelier de programmation fonctionne sur les six plates-formes avec lesquelles Flutter est compatible. Vous avez besoin de Visual Studio pour cibler Windows, de Xcode pour cibler macOS ou iOS, et d'Android Studio pour cibler Android.

2. Créer un projet

Créer votre projet Flutter

Il existe de nombreuses façons de créer un projet Flutter. Dans cette section, vous allez utiliser la ligne de commande pour plus de concision.

Pour commencer, procédez comme suit :

  1. Sur une ligne de commande, créez un projet Flutter:
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. Modifiez les dépendances du projet pour ajouter Flame et Forge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

Le package flame vous est familier, mais les trois autres peuvent nécessiter une explication. Le package characters permet de manipuler les chemins de manière conforme à UTF8. Le package flame_forge2d expose la fonctionnalité Forge2D de manière compatible avec Flame. Enfin, le package xml est utilisé à divers endroits pour consommer et modifier le contenu XML.

Ouvrez le projet, puis remplacez le contenu du fichier lib/main.dart par ce qui suit:

lib/main.dart

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

void main() {
 
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

Cela démarre l'application avec un GameWidget qui instancie l'instance FlameGame. Dans cet atelier de programmation, aucun code Flutter n'utilise l'état de l'instance de jeu pour afficher des informations sur le jeu en cours d'exécution. Par conséquent, ce bootstrap simplifié fonctionne correctement.

Facultatif: Effectuer une quête secondaire réservée à macOS

Les captures d'écran de ce projet proviennent du jeu en tant qu'application de bureau macOS. Pour éviter que la barre de titre de l'application ne vienne gâcher l'expérience globale, vous pouvez modifier la configuration du projet du lanceur macOS pour supprimer la barre de titre.

Pour cela, procédez comme suit :

  1. Créez un fichier bin/modify_macos_config.dart et ajoutez-y le contenu suivant:

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
 
var document = XmlDocument.parse(file.readAsStringSync());
 
document.xpath('//document/objects/window').first
   
..setAttribute('titlebarAppearsTransparent', 'YES')
   
..setAttribute('titleVisibility', 'hidden');
 
document
     
.xpath('//document/objects/window/windowStyleMask')
     
.first
     
.setAttribute('fullSizeContentView', 'YES');
 
file.writeAsStringSync(document.toString());
}

Ce fichier ne se trouve pas dans le répertoire lib, car il ne fait pas partie du codebase d'exécution du jeu. Il s'agit d'un outil de ligne de commande permettant de modifier le projet.

  1. À partir du répertoire de base du projet, exécutez l'outil comme suit:
dart bin/modify_macos_config.dart

Si tout se passe comme prévu, le programme ne génère aucune sortie sur la ligne de commande. Il modifiera toutefois le fichier de configuration macos/Runner/Base.lproj/MainMenu.xib pour exécuter le jeu sans barre de titre visible et avec le jeu Flame occupant toute la fenêtre.

Exécutez le jeu pour vérifier que tout fonctionne. Une nouvelle fenêtre s'ouvre, avec un arrière-plan noir vierge.

Fenêtre d'application avec un arrière-plan noir et rien au premier plan

3. Ajouter des composants Image

Ajouter les images

Tout jeu a besoin d'assets graphiques pour pouvoir peindre un écran de manière à le rendre amusant. Cet atelier de programmation utilise le pack Physics Assets de Kenney.nl. Ces éléments sont concédés sous licence Creative Commons CC0, mais je vous recommande vivement de faire un don à l'équipe Kenney afin qu'elle puisse continuer son excellent travail. Je t'ai aidé.

Vous devrez modifier le fichier de configuration pubspec.yaml pour autoriser l'utilisation des composants de Kenney. Modifiez-la comme suit:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame s'attend à ce que les composants Image se trouvent dans assets/images, bien que cela puisse être configuré différemment. Pour en savoir plus, consultez la documentation sur les images de Flame. Maintenant que vous avez configuré les chemins, vous devez les ajouter au projet lui-même. Pour ce faire, vous pouvez utiliser la ligne de commande comme suit:

mkdir -p assets/images

Aucune sortie de la commande mkdir ne devrait s'afficher, mais le nouveau répertoire devrait être visible dans votre éditeur ou dans un explorateur de fichiers.

Développez le fichier kenney_physics-assets.zip que vous avez téléchargé. Vous devriez obtenir ceci:

Fichier listant le pack kenney_physics-assets développé, avec le répertoire PNG/Backgrounds mis en évidence

À partir du répertoire PNG/Backgrounds, copiez les fichiers colored_desert.png, colored_grass.png, colored_land.png et colored_shroom.png dans le répertoire assets/images de votre projet.

Il existe également des feuilles d'éléments. Il s'agit d'une combinaison d'une image PNG et d'un fichier XML qui décrit l'emplacement des images plus petites dans l'image de la feuille de sprites. Les feuilles de sprites sont une technique permettant de réduire le temps de chargement en ne chargeant qu'un seul fichier au lieu de dizaines, voire de centaines de fichiers d'images individuels.

Liste de fichiers du pack kenney_physics-assets développée, avec le répertoire de feuilles d'animation mis en évidence

Copiez spritesheet_aliens.png, spritesheet_elements.png et spritesheet_tiles.png dans le répertoire assets/images de votre projet. Profitez-en pour copier les fichiers spritesheet_aliens.xml, spritesheet_elements.xml et spritesheet_tiles.xml dans le répertoire assets de votre projet. Votre projet doit se présenter comme suit.

Liste de fichiers du répertoire du projet forge2d_game, avec le répertoire des éléments mis en évidence

Peindre l'arrière-plan

Maintenant que des composants Image ont été ajoutés à votre projet, il est temps de les afficher à l'écran. Une seule image à l'écran. Vous en saurez plus dans les étapes suivantes.

Créez un fichier nommé background.dart dans un nouveau répertoire nommé lib/components et ajoutez le contenu suivant.

lib/components/background.dart

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

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
 
Background({required super.sprite})
   
: super(anchor: Anchor.center, position: Vector2(0, 0));

 
@override
 
void onMount() {
   
super.onMount();

   
size = Vector2.all(
     
max(
       
game.camera.visibleWorldRect.width,
       
game.camera.visibleWorldRect.height,
     
),
   
);
 
}
}

Ce composant est un SpriteComponent spécialisé. Il est chargé d'afficher l'une des quatre images de fond de Kenney.nl. Ce code repose sur quelques hypothèses simplificatrices. La première est que les images sont carrées, ce qui est le cas pour les quatre images de fond de Kenney. La seconde est que la taille du monde visible ne changera jamais, sinon ce composant devrait gérer les événements de redimensionnement du jeu. La troisième hypothèse est que la position (0,0) se trouve au centre de l'écran. Ces hypothèses nécessitent une configuration spécifique de la CameraComponent du jeu.

Créez un autre fichier, cette fois nommé game.dart, toujours dans le répertoire lib/components.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));

   
return super.onLoad();
 
}
}

Il se passe beaucoup de choses ici. Commencez par la classe MyPhysicsGame. Contrairement à l'atelier de programmation précédent, cette extension s'applique à Forge2DGame et non à FlameGame. Forge2DGame étend FlameGame avec quelques ajustements intéressants. La première est que, par défaut, zoom est défini sur 10. Ce paramètre zoom concerne la plage de valeurs utiles avec lesquelles les moteurs de simulation physique de style Box2D fonctionnent bien. Le moteur est écrit à l'aide du système MKS, où les unités sont supposées être en mètres, kilogrammes et secondes. La plage sur laquelle vous ne voyez pas d'erreurs mathématiques notables pour les objets s'étend de 0,1 mètre à plusieurs dizaines de mètres. Si vous saisissez directement des dimensions en pixels sans un certain niveau de réduction, Forge2D sortira de son enveloppe utile. Pour résumer, pensez à simuler des objets allant d'une canette de soda à un bus.

Les hypothèses faites dans le composant "Background" sont satisfaites ici en fixant la résolution de CameraComponent à 800 x 600 pixels virtuels. Cela signifie que la zone de jeu aura 80 unités de large et 60 unités de haut, et sera centrée sur (0,0). Cela n'a aucun effet sur la résolution affichée, mais cela affectera l'emplacement des objets dans la scène du jeu.

À côté de l'argument du constructeur camera, se trouve un autre argument plus aligné sur la physique, appelé gravity. La gravité est définie sur un Vector2 avec un x de 0 et un y de 10. 10 est une approximation proche de la valeur généralement acceptée de 9,81 m/s² pour la gravité. Le fait que la gravité soit définie sur 10 positif montre que, dans ce système, l'axe Y est orienté vers le bas. Ce qui est différent de Box2D en général, mais correspond à la configuration habituelle de Flame.

La méthode onLoad est la suivante. Cette méthode est asynchrone, ce qui est approprié, car elle est chargée de charger les composants Image à partir du disque. Les appels à images.load renvoient un Future<Image> et, en tant qu'effet secondaire, mettent en cache l'image chargée dans l'objet Game. Ces futures sont rassemblées et attendues en tant qu'unité unique à l'aide de la méthode statique Futures.wait. La liste des images renvoyées est ensuite mise en correspondance avec des noms individuels.

Les images de la feuille d'éléments sont ensuite transmises à une série d'objets XmlSpriteSheet chargés de récupérer les sprites nommés individuellement contenus dans la feuille d'éléments. La classe XmlSpriteSheet est définie dans le package flame_kenney_xml.

Une fois que vous avez terminé, il ne vous reste plus qu'à apporter quelques modifications mineures à lib/main.dart pour afficher une image à l'écran.

lib/main.dart

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

import 'components/game.dart';                                    // Add this import

void main() {
 
runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

Avec cette modification, vous pouvez maintenant exécuter à nouveau le jeu pour voir l'arrière-plan à l'écran. Remarque : L'instance de caméra CameraComponent.withFixedResolution() ajoutera un format letterbox si nécessaire pour que le format 800 x 600 du jeu fonctionne.

Application avec des collines verdoyantes et des arbres étrangement abstraits.

4. Ajouter le sol

Un point de départ

Si nous avons de la gravité, nous avons besoin de quelque chose pour attraper les objets du jeu avant qu'ils ne tombent en bas de l'écran. À moins que la chute hors de l'écran ne fasse partie de la conception de votre jeu, bien entendu. Créez un fichier ground.dart dans votre répertoire lib/components et ajoutez-y les éléments suivants:

lib/components/ground.dart

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

const groundSize = 7.0;

class Ground extends BodyComponent {
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Ce composant Ground dérive de BodyComponent. Dans Forge2D, les corps sont importants. Ce sont les objets qui font partie de la simulation physique bidimensionnelle. Le BodyDef de ce composant est spécifié pour avoir un BodyType.static.

Dans Forge2D, les corps sont de trois types différents. Les corps statiques ne bougent pas. Ils ont en effet une masse nulle (ils ne réagissent pas à la gravité) et une masse infinie (ils ne bougent pas lorsqu'ils sont heurtés par d'autres objets, quel que soit leur poids). Les corps statiques sont donc parfaits pour une surface au sol, car ils ne bougent pas.

Les deux autres types de corps sont les corps cinématiques et dynamiques. Les corps dynamiques sont des corps entièrement simulés. Ils réagissent à la gravité et aux objets avec lesquels ils entrent en collision. Vous verrez de nombreux corps dynamiques dans la suite de cet atelier de programmation. Les corps cinématiques se situent à mi-chemin entre les corps statiques et dynamiques. Ils bougent, mais ne réagissent pas à la gravité ni aux autres objets qui les heurtent. Utile, mais ne relève pas du cadre de cet atelier de programmation.

Le corps lui-même ne fait pas grand-chose. Un corps a besoin de formes associées pour avoir de la substance. Dans ce cas, ce corps a une forme associée, un PolygonShape défini comme BoxXY. Ce type de boîte est aligné sur l'axe du monde, contrairement à un PolygonShape défini comme BoxXY, qui peut être pivoté autour d'un point de rotation. Cela peut être utile, mais ne relève pas du champ d'application de cet atelier de programmation. La forme et le corps sont reliés par un accessoire, ce qui est utile pour ajouter des éléments tels que friction au système.

Par défaut, un corps affiche ses formes associées d'une manière utile pour le débogage, mais qui n'est pas optimale pour le jeu. Définir l'argument super renderBody sur false désactive ce rendu de débogage. C'est l'SpriteComponent enfant qui est chargé de fournir un rendu dans le jeu.

Pour ajouter le composant Ground au jeu, modifiez votre fichier game.dart comme suit.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {                               // Add from here...
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}                                                        // To here.
}

Cette modification ajoute une série de composants Ground au monde à l'aide d'une boucle for dans un contexte List, et transmet la liste de composants Ground résultante à la méthode addAll de world.

L'exécution du jeu affiche désormais l'arrière-plan et le sol.

Fenêtre d&#39;application avec un arrière-plan et une couche de sol.

5. Ajouter les briques

Créer un mur

Le sol nous a donné un exemple de corps statique. Il est maintenant temps de créer votre premier composant dynamique. Les composants dynamiques de Forge2D sont la pierre angulaire de l'expérience du joueur. Ce sont les éléments qui se déplacent et interagissent avec le monde qui les entoure. À cette étape, vous allez ajouter des briques qui seront choisies de manière aléatoire pour s'afficher à l'écran dans un groupe de briques. Vous verrez les objets tomber et se cogner les uns aux autres.

Les briques seront créées à partir de la feuille de sprites des éléments. Si vous examinez la description de la feuille d'éléments dans assets/spritesheet_elements.xml, vous constaterez que nous avons un problème intéressant. Les noms ne semblent pas très utiles. Il serait utile de pouvoir sélectionner une brique par type de matériau, par taille et par degré de dommage. Heureusement, un elfe serviable a passé un certain temps à comprendre le schéma de dénomination des fichiers et a créé un outil pour vous faciliter la tâche. Créez un fichier generate_brick_file_names.dart dans le répertoire bin et ajoutez le contenu suivant:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('assets/spritesheet_elements.xml');
 
final rects = <String, Rect>{};
 
final document = XmlDocument.parse(file.readAsStringSync());
 
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
   
final name = node.getAttribute('name')!;
   
rects[name] = Rect(
     
x: int.parse(node.getAttribute('x')!),
     
y: int.parse(node.getAttribute('y')!),
     
width: int.parse(node.getAttribute('width')!),
     
height: int.parse(node.getAttribute('height')!),
   
);
 
}
 
print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
 
final int x;
 
final int y;
 
final int width;
 
final int height;
 
const Rect({
   
required this.x,
   
required this.y,
   
required this.width,
   
required this.height,
 
});

 
Size get size => Size(width, height);

 
@override
 
List<Object?> get props => [x, y, width, height];

 
@override
 
bool get stringify => true;
}

class Size extends Equatable {
 
final int width;
 
final int height;
 
const Size(this.width, this.height);

 
@override
 
List<Object?> get props => [width, height];

 
@override
 
bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
 
final groups = <Size, List<String>>{};
 
for (final entry in rects.entries) {
   
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
 
}
 
final buff = StringBuffer();
 
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
 
for (final entry in groups.entries) {
   
final size = entry.key;
   
final entries = entry.value;
   
entries.sort();
   
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
     
var filtered = entries.where((element) => element.contains(type));
     
if (filtered.length == 5) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
     
} else if (filtered.length == 10) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
     
} else if (filtered.length == 15) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
     
}
   
}
 
}
 
buff.writeln('''
  };
}''');
 
return buff.toString();
}

Votre éditeur doit vous envoyer un avertissement ou une erreur concernant une dépendance manquante. Ajoutez-le à l'aide de la commande suivante:

flutter pub add equatable

Vous devriez maintenant pouvoir exécuter ce programme comme suit:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

Cet outil a analysé le fichier de description de la feuille d'animation et l'a converti en code Dart que nous pouvons utiliser pour sélectionner le fichier image approprié pour chaque brique que vous souhaitez afficher à l'écran. Pratique !

Créez le fichier brick.dart avec le contenu suivant:

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
 
explosive(density: 1, friction: 0.5),
 
glass(density: 0.5, friction: 0.2),
 
metal(density: 1, friction: 0.4),
 
stone(density: 2, friction: 1),
 
wood(density: 0.25, friction: 0.6);

 
final double density;
 
final double friction;

 
const BrickType({required this.density, required this.friction});
 
static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
 
size70x70(ui.Size(70, 70)),
 
size140x70(ui.Size(140, 70)),
 
size220x70(ui.Size(220, 70)),
 
size70x140(ui.Size(70, 140)),
 
size140x140(ui.Size(140, 140)),
 
size220x140(ui.Size(220, 140)),
 
size140x220(ui.Size(140, 220)),
 
size70x220(ui.Size(70, 220));

 
final ui.Size size;

 
const BrickSize(this.size);

 
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
 
return switch ((type, size)) {
   
(BrickType.explosive, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementExplosive009.png',
     
BrickDamage.some: 'elementExplosive012.png',
     
BrickDamage.lots: 'elementExplosive050.png',
   
},
   
(BrickType.glass, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementGlass010.png',
     
BrickDamage.some: 'elementGlass013.png',
     
BrickDamage.lots: 'elementGlass048.png',
   
},
   
(BrickType.metal, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementMetal009.png',
     
BrickDamage.some: 'elementMetal012.png',
     
BrickDamage.lots: 'elementMetal050.png',
   
},
   
(BrickType.stone, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementStone009.png',
     
BrickDamage.some: 'elementStone012.png',
     
BrickDamage.lots: 'elementStone047.png',
   
},
   
(BrickType.wood, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementWood011.png',
     
BrickDamage.some: 'elementWood014.png',
     
BrickDamage.lots: 'elementWood054.png',
   
},
   
(BrickType.explosive, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementExplosive011.png',
     
BrickDamage.some: 'elementExplosive014.png',
     
BrickDamage.lots: 'elementExplosive049.png',
   
},
   
(BrickType.glass, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementGlass011.png',
     
BrickDamage.some: 'elementGlass012.png',
     
BrickDamage.lots: 'elementGlass046.png',
   
},
   
(BrickType.metal, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementMetal011.png',
     
BrickDamage.some: 'elementMetal014.png',
     
BrickDamage.lots: 'elementMetal049.png',
   
},
   
(BrickType.stone, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementStone011.png',
     
BrickDamage.some: 'elementStone014.png',
     
BrickDamage.lots: 'elementStone046.png',
   
},
   
(BrickType.wood, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementWood010.png',
     
BrickDamage.some: 'elementWood013.png',
     
BrickDamage.lots: 'elementWood045.png',
   
},
   
(BrickType.explosive, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementExplosive013.png',
     
BrickDamage.some: 'elementExplosive016.png',
     
BrickDamage.lots: 'elementExplosive051.png',
   
},
   
(BrickType.glass, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementGlass014.png',
     
BrickDamage.some: 'elementGlass017.png',
     
BrickDamage.lots: 'elementGlass049.png',
   
},
   
(BrickType.metal, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementMetal013.png',
     
BrickDamage.some: 'elementMetal016.png',
     
BrickDamage.lots: 'elementMetal051.png',
   
},
   
(BrickType.stone, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementStone013.png',
     
BrickDamage.some: 'elementStone016.png',
     
BrickDamage.lots: 'elementStone048.png',
   
},
   
(BrickType.wood, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementWood012.png',
     
BrickDamage.some: 'elementWood015.png',
     
BrickDamage.lots: 'elementWood047.png',
   
},
   
(BrickType.explosive, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementExplosive017.png',
     
BrickDamage.some: 'elementExplosive022.png',
     
BrickDamage.lots: 'elementExplosive052.png',
   
},
   
(BrickType.glass, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementGlass018.png',
     
BrickDamage.some: 'elementGlass023.png',
     
BrickDamage.lots: 'elementGlass050.png',
   
},
   
(BrickType.metal, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementMetal017.png',
     
BrickDamage.some: 'elementMetal022.png',
     
BrickDamage.lots: 'elementMetal052.png',
   
},
   
(BrickType.stone, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementStone017.png',
     
BrickDamage.some: 'elementStone022.png',
     
BrickDamage.lots: 'elementStone049.png',
   
},
   
(BrickType.wood, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementWood016.png',
     
BrickDamage.some: 'elementWood021.png',
     
BrickDamage.lots: 'elementWood048.png',
   
},
   
(BrickType.explosive, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementExplosive018.png',
     
BrickDamage.some: 'elementExplosive023.png',
     
BrickDamage.lots: 'elementExplosive053.png',
   
},
   
(BrickType.glass, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementGlass019.png',
     
BrickDamage.some: 'elementGlass024.png',
     
BrickDamage.lots: 'elementGlass051.png',
   
},
   
(BrickType.metal, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementMetal018.png',
     
BrickDamage.some: 'elementMetal023.png',
     
BrickDamage.lots: 'elementMetal053.png',
   
},
   
(BrickType.stone, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementStone018.png',
     
BrickDamage.some: 'elementStone023.png',
     
BrickDamage.lots: 'elementStone050.png',
   
},
   
(BrickType.wood, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementWood017.png',
     
BrickDamage.some: 'elementWood022.png',
     
BrickDamage.lots: 'elementWood049.png',
   
},
   
(BrickType.explosive, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementExplosive019.png',
     
BrickDamage.some: 'elementExplosive024.png',
     
BrickDamage.lots: 'elementExplosive054.png',
   
},
   
(BrickType.glass, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementGlass020.png',
     
BrickDamage.some: 'elementGlass025.png',
     
BrickDamage.lots: 'elementGlass052.png',
   
},
   
(BrickType.metal, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementMetal019.png',
     
BrickDamage.some: 'elementMetal024.png',
     
BrickDamage.lots: 'elementMetal054.png',
   
},
   
(BrickType.stone, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementStone019.png',
     
BrickDamage.some: 'elementStone024.png',
     
BrickDamage.lots: 'elementStone051.png',
   
},
   
(BrickType.wood, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementWood018.png',
     
BrickDamage.some: 'elementWood023.png',
     
BrickDamage.lots: 'elementWood050.png',
   
},
   
(BrickType.explosive, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementExplosive020.png',
     
BrickDamage.some: 'elementExplosive025.png',
     
BrickDamage.lots: 'elementExplosive055.png',
   
},
   
(BrickType.glass, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementGlass021.png',
     
BrickDamage.some: 'elementGlass026.png',
     
BrickDamage.lots: 'elementGlass053.png',
   
},
   
(BrickType.metal, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementMetal020.png',
     
BrickDamage.some: 'elementMetal025.png',
     
BrickDamage.lots: 'elementMetal055.png',
   
},
   
(BrickType.stone, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementStone020.png',
     
BrickDamage.some: 'elementStone025.png',
     
BrickDamage.lots: 'elementStone052.png',
   
},
   
(BrickType.wood, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementWood019.png',
     
BrickDamage.some: 'elementWood024.png',
     
BrickDamage.lots: 'elementWood051.png',
   
},
   
(BrickType.explosive, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementExplosive021.png',
     
BrickDamage.some: 'elementExplosive026.png',
     
BrickDamage.lots: 'elementExplosive056.png',
   
},
   
(BrickType.glass, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementGlass022.png',
     
BrickDamage.some: 'elementGlass027.png',
     
BrickDamage.lots: 'elementGlass054.png',
   
},
   
(BrickType.metal, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementMetal021.png',
     
BrickDamage.some: 'elementMetal026.png',
     
BrickDamage.lots: 'elementMetal056.png',
   
},
   
(BrickType.stone, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementStone021.png',
     
BrickDamage.some: 'elementStone026.png',
     
BrickDamage.lots: 'elementStone053.png',
   
},
   
(BrickType.wood, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementWood020.png',
     
BrickDamage.some: 'elementWood025.png',
     
BrickDamage.lots: 'elementWood052.png',
   
},
 
};
}

class Brick extends BodyComponent {
 
Brick({
   
required this.type,
   
required this.size,
   
required BrickDamage damage,
   
required Vector2 position,
   
required Map<BrickDamage, Sprite> sprites,
 
}) : _damage = damage,
       
_sprites = sprites,
       
super(
         
renderBody: false,
         
bodyDef: BodyDef()
           
..position = position
           
..type = BodyType.dynamic,
         
fixtureDefs: [
           
FixtureDef(
               
PolygonShape()..setAsBoxXY(
                 
size.size.width / 20 * brickScale,
                 
size.size.height / 20 * brickScale,
               
),
             
)
             
..restitution = 0.4
             
..density = type.density
             
..friction = type.friction,
         
],
       
);

 
late final SpriteComponent _spriteComponent;

 
final BrickType type;
 
final BrickSize size;
 
final Map<BrickDamage, Sprite> _sprites;

 
BrickDamage _damage;
 
BrickDamage get damage => _damage;
 
set damage(BrickDamage value) {
   
_damage = value;
   
_spriteComponent.sprite = _sprites[value];
 
}

 
@override
 
Future<void> onLoad() {
   
_spriteComponent = SpriteComponent(
     
anchor: Anchor.center,
     
scale: Vector2.all(1),
     
sprite: _sprites[_damage],
     
size: size.size.toVector2() / 10 * brickScale,
     
position: Vector2(0, 0),
   
);
   
add(_spriteComponent);
   
return super.onLoad();
 
}
}

Vous pouvez maintenant voir comment le code Dart généré précédemment est intégré à ce codebase pour vous permettre de sélectionner rapidement des images de briques en fonction du matériau, de la taille et de l'état. En regardant au-delà des enum et du composant Brick lui-même, vous devriez constater que la majeure partie de ce code semble assez familière du composant Ground de l'étape précédente. Un état modifiable est ici utilisé pour permettre à la brique d'être endommagée, bien que l'utilisation de cet état soit laissée en exercice au lecteur.

Il est temps de placer les briques à l'écran. Modifiez le fichier game.dart comme suit :

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());                                // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();                                // Add from here...

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}                                                        // To here.
}

Cet ajout de code est un peu différent du code que vous avez utilisé pour ajouter les composants Ground. Cette fois, les Brick sont ajoutées dans un cluster aléatoire, au fil du temps. Cette opération se compose de deux parties. La première consiste à ajouter les awaits des Bricks à un Future.delayed, qui est l'équivalent asynchrone d'un appel sleep(). Toutefois, il existe une deuxième partie pour que cela fonctionne. L'appel de addBricks dans la méthode onLoad n'est pas awaité. Si c'était le cas, la méthode onLoad ne se terminerait pas tant que toutes les briques n'étaient pas à l'écran. Encapsulant l'appel à addBricks dans un appel unawaited, les outils de linting sont satisfaits et notre intention est claire pour les futurs programmeurs. L'attente de la valeur renvoyée par cette méthode est intentionnelle.

Exécutez le jeu. Vous verrez des briques apparaître, se heurter et se répandre sur le sol.

Fenêtre d&#39;application avec des collines vertes en arrière-plan, une couche de sol et des blocs qui s&#39;y posent.

6. Ajouter le lecteur

Lancer des extraterrestres sur des briques

Regarder les briques s'effondrer est amusant les premières fois, mais je pense que ce jeu sera plus amusant si nous donnons au joueur un avatar qu'il pourra utiliser pour interagir avec le monde. Que pensez-vous d'un extraterrestre qu'il peut lancer sur les briques ?

Créez un fichier player.dart dans le répertoire lib/components et ajoutez-y les éléments suivants:

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
 
pink,
 
blue,
 
green,
 
yellow;

 
static PlayerColor get randomColor =>
     
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

 
String get fileName =>
     
'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
 
Player(Vector2 position, Sprite sprite)
   
: _sprite = sprite,
     
super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static
         
..angularDamping = 0.1
         
..linearDamping = 0.1,
       
fixtureDefs: [
         
FixtureDef(CircleShape()..radius = playerSize / 2)
           
..restitution = 0.4
           
..density = 0.75
           
..friction = 0.5,
       
],
     
);

 
final Sprite _sprite;

 
@override
 
Future<void> onLoad() {
   
addAll([
     
CustomPainterComponent(
       
painter: _DragPainter(this),
       
anchor: Anchor.center,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
     
SpriteComponent(
       
anchor: Anchor.center,
       
sprite: _sprite,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
   
]);
   
return super.onLoad();
 
}

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

   
if (!body.isAwake) {
     
removeFromParent();
   
}

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}

 
Vector2 _dragStart = Vector2.zero();
 
Vector2 _dragDelta = Vector2.zero();
 
Vector2 get dragDelta => _dragDelta;

 
@override
 
void onDragStart(DragStartEvent event) {
   
super.onDragStart(event);
   
if (body.bodyType == BodyType.static) {
     
_dragStart = event.localPosition;
   
}
 
}

 
@override
 
void onDragUpdate(DragUpdateEvent event) {
   
if (body.bodyType == BodyType.static) {
     
_dragDelta = event.localEndPosition - _dragStart;
   
}
 
}

 
@override
 
void onDragEnd(DragEndEvent event) {
   
super.onDragEnd(event);
   
if (body.bodyType == BodyType.static) {
     
children
         
.whereType<CustomPainterComponent>()
         
.firstOrNull
         
?.removeFromParent();
     
body.setType(BodyType.dynamic);
     
body.applyLinearImpulse(_dragDelta * -50);
     
add(RemoveEffect(delay: 5.0));
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
 
_DragPainter(this.player);

 
final Player player;

 
@override
 
void paint(Canvas canvas, Size size) {
   
if (player.dragDelta != Vector2.zero()) {
     
var center = size.center(Offset.zero);
     
canvas.drawLine(
       
center,
       
center + (player.dragDelta * -1).toOffset(),
       
Paint()
         
..color = Colors.orange.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

 
@override
 
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Il s'agit d'une amélioration par rapport aux composants Brick de l'étape précédente. Ce composant Player comporte deux composants enfants, un SpriteComponent que vous devriez reconnaître et un CustomPainterComponent qui est nouveau. Le concept CustomPainter provient de Flutter et vous permet de peindre sur un canevas. Il est utilisé ici pour indiquer au joueur où l'extraterrestre rond va voler lorsqu'il est lancé.

Comment le joueur lance-t-il l'extraterrestre ? À l'aide d'un geste de glissement, que le composant Player détecte avec les rappels DragCallbacks. Les plus observateurs d'entre vous auront remarqué autre chose.

Les composants Ground étaient des corps statiques, tandis que les composants Brick étaient des corps dynamiques. Le lecteur est une combinaison des deux. Le joueur commence par être statique, attendant que le joueur le fasse glisser. Lorsque le joueur relâche le bouton de glissement, le joueur passe de statique à dynamique, ajoute une impulsion linéaire proportionnelle au glissement et permet à l'avatar extraterrestre de voler.

Le composant Player contient également du code permettant de le supprimer de l'écran s'il sort du cadre, s'il passe en veille ou si le délai avant expiration est dépassé. L'objectif est de permettre au joueur de lancer l'extraterrestre, de voir ce qui se passe, puis de recommencer.

Intégrez le composant Player au jeu en modifiant game.dart comme suit:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());
   
await addPlayer();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(             // Add from here...
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted && world.children.whereType<Player>().isEmpty) {
     
addPlayer();
   
}
 
}                                                        // To here.
}

L'ajout du joueur au jeu est semblable aux composants précédents, avec une petite subtilité. L'extraterrestre du joueur est conçu pour s'autodétruire dans le jeu sous certaines conditions. Un gestionnaire de mise à jour vérifie donc si aucun composant Player n'est présent dans le jeu et, le cas échéant, en ajoute un. Voici à quoi ressemble l'exécution du jeu.

Fenêtre d&#39;application avec des collines vertes en arrière-plan, une couche de sol, des blocs sur le sol et un avatar de joueur en vol.

7. Réagir à l&#39;impact

Ajouter les ennemis

Vous avez vu des objets statiques et dynamiques interagir les uns avec les autres. Toutefois, pour vraiment avancer, vous devez obtenir des rappels dans le code lorsque des éléments se chevauchent. Vous allez introduire des ennemis que le joueur devra affronter. Cela permet d'obtenir une condition de victoire : supprimer tous les ennemis du jeu.

Créez un fichier enemy.dart dans le répertoire lib/components et ajoutez les éléments suivants:

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
 
pink(color: 'pink', boss: false),
 
blue(color: 'blue', boss: false),
 
green(color: 'green', boss: false),
 
yellow(color: 'yellow', boss: false),
 
pinkBoss(color: 'pink', boss: true),
 
blueBoss(color: 'blue', boss: true),
 
greenBoss(color: 'green', boss: true),
 
yellowBoss(color: 'yellow', boss: true);

 
final bool boss;
 
final String color;

 
const EnemyColor({required this.color, required this.boss});

 
static EnemyColor get randomColor =>
     
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

 
String get fileName =>
     
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
 
Enemy(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.dynamic,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(enemySize),
           
position: Vector2(0, 0),
         
),
       
],
     
);

 
@override
 
void beginContact(Object other, Contact contact) {
   
var interceptVelocity =
       
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
           
.abs();
   
if (interceptVelocity > 35) {
     
removeFromParent();
   
}

   
super.beginContact(other, contact);
 
}

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

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

Compte tenu de vos interactions précédentes avec les composants Player et Brick, la majeure partie de ce fichier devrait vous être familière. Toutefois, quelques lignes sont soulignées en rouge dans l'éditeur en raison d'une nouvelle classe de base inconnue. Ajoutez cette classe maintenant en ajoutant un fichier nommé body_component_with_user_data.dart à lib/components avec le contenu suivant:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
 
BodyComponentWithUserData({
   
super.key,
   
super.bodyDef,
   
super.children,
   
super.fixtureDefs,
   
super.paint,
   
super.priority,
   
super.renderBody,
 
});

 
@override
 
Body createBody() {
   
final body = world.createBody(super.bodyDef!)..userData = this;
   
fixtureDefs?.forEach(body.createFixture);
   
return body;
 
}
}

Cette classe de base, combinée au nouveau rappel beginContact dans le composant Enemy, constitue la base de la notification par programmation des impacts entre les corps. En fait, vous devrez modifier les composants pour lesquels vous souhaitez recevoir des notifications d'impact. Modifiez donc les composants Brick, Ground et Player pour utiliser ce BodyComponentWithUserData à la place de la classe de base BodyComponent qu'ils utilisent. Par exemple, voici comment modifier le composant Ground:

lib/components/ground.dart

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

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Pour en savoir plus sur la façon dont Forge2d gère les contacts, consultez la documentation Forge2D sur les rappels de contact.

Gagner le match

Maintenant que vous avez des ennemis et un moyen de les supprimer du monde, il existe un moyen simple de transformer cette simulation en jeu. Votre objectif est de supprimer tous les ennemis. Il est temps de modifier le fichier game.dart comme suit:

lib/components/game.dart

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

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

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
   
await addPlayer();

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted &&                                       // Modify from here...
       
world.children.whereType<Player>().isEmpty &&
       
world.children.whereType<Enemy>().isNotEmpty) {
     
addPlayer();
   
}
   
if (isMounted &&
       
enemiesFullyAdded &&
       
world.children.whereType<Enemy>().isEmpty &&
       
world.children.whereType<TextComponent>().isEmpty) {
     
world.addAll(
       
[
         
(position: Vector2(0.5, 0.5), color: Colors.white),
         
(position: Vector2.zero(), color: Colors.orangeAccent),
       
].map(
         
(e) => TextComponent(
           
text: 'You win!',
           
anchor: Anchor.center,
           
position: e.position,
           
textRenderer: TextPaint(
             
style: TextStyle(color: e.color, fontSize: 16),
           
),
         
),
       
),
     
);
   
}
 
}

 
var enemiesFullyAdded = false;

 
Future<void> addEnemies() async {
   
await Future<void>.delayed(const Duration(seconds: 2));
   
for (var i = 0; i < 3; i++) {
     
await world.add(
       
Enemy(
         
Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 7 - 3.5),
           
(_random.nextDouble() * 3),
         
),
         
aliens.getSprite(EnemyColor.randomColor.fileName),
       
),
     
);
     
await Future<void>.delayed(const Duration(seconds: 1));
   
}
   
enemiesFullyAdded = true;                              // To here.
 
}
}

Votre défi, si vous l'acceptez, est d'exécuter le jeu et d'accéder à cet écran.

Fenêtre d&#39;application avec des collines vertes en arrière-plan, une couche de sol, des blocs sur le sol et un texte superposé &quot;Vous avez gagné !&quot;

8. Félicitations

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

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

Étape suivante

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

Documentation complémentaire