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

1. Avant de commencer

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

Animation montrant le gameplay avec ce jeu de physique 2D

Prérequis

Objectifs

  • Découvrez les principes 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 choisie. Cet atelier de programmation fonctionne pour les six plates-formes compatibles avec Flutter. Visual Studio doit cibler Windows, Xcode pour macOS ou iOS, et 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 par souci 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.
  1. 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.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Vous connaissez déjà le package flame, mais les trois autres auront peut-être besoin d'explications. Le package characters est utilisé pour manipuler le chemin d'accès aux fichiers dans le respect de la norme UTF8. Le package flame_forge2d expose la fonctionnalité Forge2D d'une manière qui fonctionne bien avec Flame. Enfin, le package xml est utilisé à différents endroits pour consommer et modifier du 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,
    ),
  );
}

Cette commande 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. Ce démarrage simplifié fonctionne donc parfaitement.

Facultatif: Suivre une quête secondaire uniquement sur 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 gêne l'expérience globale, vous pouvez modifier la configuration du projet de l'exécuteur macOS afin de la supprimer.

Pour cela, procédez comme suit :

  1. Créez un fichier bin/modify_macos_config.dart et ajoutez 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 pendant l'exécution du jeu. Il s'agit d'un outil de ligne de commande utilisé pour 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érera aucun résultat dans la ligne de commande. Toutefois, il modifiera 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 correctement. Une nouvelle fenêtre ne doit s'afficher qu'avec un arrière-plan noir vide.

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

3. Ajouter des composants Image

Ajouter les images

Les jeux ont besoin d'éléments artistiques pour peindre un écran de façon à s'amuser. Cet atelier de programmation utilise le pack Physics Assets (Ressources physiques) de Kenney.nl. Ces éléments sont concédés sous licence Creative Commons CC0, mais je recommande fortement de faire un don à l'équipe de Kenney pour qu'elle puisse poursuivre son travail. Je t'ai aidé.

Vous devez modifier le fichier de configuration pubspec.yaml pour permettre l'utilisation des ressources 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.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

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 les chemins d'accès sont configurés, vous devez les ajouter au projet lui-même. Pour ce faire, vous pouvez utiliser la ligne de commande suivante:

$ mkdir -p assets/images

La commande mkdir ne devrait pas générer de résultat, mais le nouveau répertoire doit ê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 un résultat semblable à celui-ci:

Fichier listant le pack kenney_physical-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 grilles de sprites. Il s'agit d'une combinaison d'une image PNG et d'un fichier XML indiquant où se trouvent les images plus petites dans l'image de la feuille de sprites. Les feuilles de sprites sont une technique qui permet de réduire le temps de chargement en ne chargeant qu'un seul fichier, et non des dizaines, voire des centaines, de fichiers image individuels.

Fichier listant le pack kenney_physical-assets développé, avec le répertoire Spritesheet mis en évidence

Copiez spritesheet_aliens.png, spritesheet_elements.png et spritesheet_tiles.png dans le répertoire assets/images de votre projet. Pendant que vous y êtes, copiez également 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.

Fichier listant le répertoire du projet forge2d_game, avec le répertoire des éléments en surbrillance

Peindre l'arrière-plan

Maintenant que les composants Image ont été ajoutés à votre projet, il est temps de les afficher à l'écran. Eh bien, une seule image à l'écran. Vous en aurez d'autres au cours des étapes suivantes.

Créez un fichier nommé background.dart dans un nouveau répertoire intitulé 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 responsable de l'affichage de l'une des quatre images de fond de Kenney.nl. Ce code comporte quelques hypothèses simplificatrices. La première est que les images sont carrées, ce que sont 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) sera au centre de l'écran. Ces hypothèses nécessitent une configuration spécifique du CameraComponent du jeu.

Créez un autre fichier nommé game.dart 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. Commençons par la classe MyPhysicsGame. Contrairement à l'atelier de programmation précédent, cela étend Forge2DGame et non FlameGame. Forge2DGame elle-même é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 exprimées en mètres, en kilogrammes et en secondes. La portée d'un objet est comprise entre 0,1 mètre et plusieurs dizaines de mètres. Si vous transmettez directement les dimensions en pixels sans un certain niveau de réduction de la taille de l'image, Forge2D sortirait de son enveloppe. Pour résumer, il s'agit de simuler des objets situés entre une canette de soda et un bus.

Les hypothèses formulées dans le composant "Background" (Arrière-plan) sont satisfaites ici en corrigeant la résolution de CameraComponent à 800 x 600 pixels virtuels. Cela signifie que la zone de jeu fera 80 unités de large et 60 unités de haut, centrées sur la position (0,0). Cela n'a aucune incidence sur la résolution d'affichage, mais elle affectera l'emplacement des objets dans la scène du jeu.

En plus de l'argument de constructeur camera, il existe un autre argument aligné plus en termes de physique, appelé gravity. La gravité est définie sur Vector2, avec une x de 0 et une y de 10. Le 10 est une approximation proche de la valeur généralement acceptée de 9,81 mètres par seconde par seconde pour la gravité. Le fait que la gravité soit réglée sur 10 positif montre que dans ce système, la direction de l'axe Y est descendante. Ce qui diffère globalement de Box2D, mais correspond à la configuration habituelle de Flame.

Passons à la méthode onLoad. Cette méthode est asynchrone, ce qui convient dans la mesure où elle est chargée de charger les éléments image à partir du disque. Les appels à images.load renvoient un Future<Image> et, comme effet secondaire, mettent en cache l'image chargée dans l'objet Game. Ces objets Future sont rassemblés et attendus comme une seule unité à 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 de sprites sont ensuite transmises à une série d'objets XmlSpriteSheet chargés de récupérer les sprites individuellement nommés dans la feuille de sprites. La classe XmlSpriteSheet est définie dans le package flame_kenney_xml.

Vous n'avez que quelques modifications mineures à apporter à 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
    ),
  );
}

Grâce à cette simple modification, vous pouvez de nouveau exécuter le jeu pour voir l'arrière-plan à l'écran. Notez que l'instance de caméra CameraComponent.withFixedResolution() ajoute le format letterbox si nécessaire pour que le ratio 800 x 600 du jeu fonctionne.

Fenêtre d&#39;application avec l&#39;image de fond représentant des collines verdoyantes et des arbres étrangement abstraits.

4. Ajouter le sol

Un élément sur lequel s'appuyer

Avec la gravité, nous avons besoin de quelque chose pour attraper les objets dans le jeu avant qu'ils ne tombent du bas de l'écran. Sauf si la chute de l'écran fait partie de la conception de votre jeu, bien sûr. Créez un fichier ground.dart dans votre répertoire lib/components et ajoutez-y ce qui suit:

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 est dérivé de BodyComponent. Dans Forge2D, les corps sont importants. Ce sont les objets qui font partie de la simulation physique en deux dimensions. Le BodyDef de ce composant doit avoir un BodyType.static.

Dans Forge2D, les corps ont trois types différents. Les corps statiques ne bougent pas. Dans les faits, ils ont à la fois une masse nulle (incapable de réagir à la gravité) et une masse infinie, qui ne bougent pas lorsqu'ils sont heurtés par d'autres objets, quelle que soit leur poids. Cela rend les corps statiques parfaits pour une surface au sol, car ils ne bougent pas.

Les deux autres types de corps sont cinématiques et dynamiques. Les corps dynamiques sont des corps entièrement simulés. Ils réagissent à la gravité et aux objets auxquels ils se heurtent. Vous verrez de nombreux corps dynamiques dans le reste de cet atelier de programmation. Les corps cinématiques sont à mi-chemin entre statique et dynamique. Ils bougent, mais ne réagissent pas à la gravité ou aux autres objets qui les frappent. Utile, mais dépasse le 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 contenir une substance. Dans ce cas, ce corps est associé à une forme, un PolygonShape défini en tant que BoxXY. Ce type de boîte correspond à un axe aligné avec le monde, contrairement à une PolygonShape définie en tant que BoxXY, qui peut être pivotée autour d'un point de rotation. C'est aussi utile, mais n'entre pas dans le cadre de cet atelier de programmation. La forme et le corps sont reliés par un équipement, ce qui est utile pour ajouter des éléments tels que friction au système.

Par défaut, un corps affiche les formes qui lui sont associées d'une manière utile pour le débogage, mais pas pour un gameplay optimal. Définir l'argument super renderBody sur false désactive ce rendu de débogage. Il incombe à l'enfant SpriteComponent d'attribuer à ce corps 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 en utilisant une boucle for dans un contexte List et en transmettant la liste de composants Ground obtenue à la méthode addAll de world.

Lorsque vous exécutez le jeu, vous voyez maintenant l'arrière-plan et le sol.

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

5. Ajouter les briques

Construire un mur

Le sol nous a donné un exemple de corps statique. Il est 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 bougent et interagissent avec le monde qui l'entoure. Au cours de cette étape, vous allez présenter des briques choisies de manière aléatoire pour s'afficher à l'écran dans un ensemble de briques. Vous les verrez tomber et se cogner l'un sur l'autre pendant qu'ils se trouvent.

Les briques seront créées à partir de la feuille de sprites des éléments. Si vous examinez la description de la feuille de sprites dans assets/spritesheet_elements.xml, vous verrez que nous avons un problème intéressant. Les noms ne semblent pas très utiles. Ce qui serait utile serait de pouvoir sélectionner une brique par type de matériau, sa taille et le niveau de dégâts. Heureusement, un lutin utile a pris le temps de comprendre le modèle dans le nommage 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 devrait vous signaler un avertissement ou une erreur concernant une dépendance manquante. Ajoutez-le comme suit:

$ flutter pub add equatable

Vous devriez maintenant être en mesure d'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 de sprites et l'a converti en code Dart que nous pouvons utiliser pour sélectionner le fichier image approprié pour chaque brique à afficher à l'écran. Utile !

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é ci-dessus est intégré à ce codebase pour permettre de sélectionner rapidement et facilement des images de briques en fonction du matériau, de la taille et de l'état. En observant au-delà des enum et sur le composant Brick lui-même, vous devriez constater que la majeure partie de ce code vous semble assez familier avec le composant Ground de l'étape précédente. Ici, l'état est modifiable et permet d'endommager la brique, bien que l'utilisation de cet état reste un exercice pour le lecteur.

Il est temps d'afficher 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 diffère légèrement de celui que vous avez utilisé pour ajouter les composants Ground. Cette fois, les Brick sont ajoutés dans un cluster aléatoire, au fil du temps. Cette opération comporte deux parties. La première est la méthode qui ajoute les await de Brick à un Future.delayed, ce qui est l'équivalent asynchrone d'un appel sleep(). Cependant, il existe une deuxième partie pour que ce fonctionnement 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 que lorsque toutes les briques étaient affichées à l'écran. Encapsuler l'appel à addBricks dans un appel unawaited rend les linters heureux et rend notre intention évidente pour les futurs programmeurs. Ne pas attendre le retour de cette méthode est intentionnel.

Exécutez le jeu et vous verrez des briques apparaître, se cogner et se renverser sur le sol.

Fenêtre d&#39;application avec des collines vertes en arrière-plan, un calque au sol et des blocs atterrissant au sol.

6. Ajouter le joueur

Lancer des extraterrestres sur des briques

C'est amusant de regarder des briques tomber les premières fois, mais je suppose que ce jeu sera plus amusant si nous donnons au joueur un avatar qu'il peut utiliser pour interagir avec le monde. Que diriez-vous d'un extraterrestre qu'ils pourraient jeter en jetant sur les briques ?

Créez un fichier player.dart dans le répertoire lib/components et ajoutez-y ce qui suit:

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.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

Il s'agit d'une étape par rapport aux composants Brick de l'étape précédente. Ce composant Player comporte deux composants enfants : un SpriteComponent que vous devez reconnaître et un CustomPainterComponent nouveau. Le concept CustomPainter, né de Flutter, vous permet de peindre sur un canevas. Elle permet d'indiquer au joueur où l'extraterrestre va voler une fois qu'il est lancé.

Comment le joueur lance-t-il le glissement de l'extraterrestre ? Utiliser un geste de déplacement, que le composant "Player" détecte à l'aide des rappels DragCallbacks. Vos yeux de aigle auront remarqué autre chose ici.

Les composants Ground étaient des corps statiques, et les composants Brick étaient des corps dynamiques. Le lecteur est une combinaison des deux. Le joueur démarre comme un personnage statique et attend qu'il le fasse glisser. Lorsque l'utilisateur relâche le bouton, il se transforme de statique en dynamique, ajoute une impulsion linéaire proportionnelle à la traînée et laisse l'avatar extraterrestre voler.

Le composant Player contient également du code permettant de le supprimer de l'écran s'il est en dehors des limites, s'il s'endort ou s'il expire. L'objectif ici est de permettre au joueur de renverser l'extraterrestre, de voir ce qui se passe, puis d'essayer à nouveau.

Intégrez le composant Player dans le 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 s'effectue de la même manière que pour les éléments précédents, avec un trait supplémentaire. L'extraterrestre du joueur est conçu pour se retirer du jeu sous certaines conditions. Un gestionnaire de mise à jour permet donc de vérifier s'il n'y a pas de composant Player dans le jeu et d'en ajouter un, le cas échéant. L'exécution du jeu ressemble à ceci.

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

7. Réagir à l'impact

Ajouter les ennemis

Vous avez vu des objets statiques et dynamiques interagir les uns avec les autres. Cependant, pour vous rendre quelque part, vous devez recevoir des rappels dans le code en cas de conflit. Voyons comment procéder. Vous allez présenter au joueur des ennemis à affronter. Vous pourrez ainsi remporter la victoire : éliminez 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();
}

D'après vos précédentes interactions avec les composants Player et Brique, la majeure partie de ce fichier doit vous être familière. Toutefois, votre éditeur affiche quelques traits de soulignement rouges en raison d'une nouvelle classe de base inconnue. Ajoutez cette classe maintenant en ajoutant à lib/components un fichier nommé body_component_with_user_data.dart 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, permet de recevoir des notifications programmatiques en cas d'impact entre les corps. Vous devrez d'ailleurs modifier les composants pour lesquels vous souhaitez recevoir des notifications d'impact. Modifiez donc les composants Brick, Ground et Player pour utiliser BodyComponentWithUserData à la place de la classe de base BodyComponent que ces composants utilisent actuellement. 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 manière dont Forge2d gère les contacts, consultez la documentation de Forge2D sur les rappels de contact.

Gagner la partie

Maintenant que vous avez des ennemis et que vous avez la possibilité de les éliminer du monde, il existe un moyen simple de transformer cette simulation en jeu. Fixez-vous comme objectif d'éliminer tous les ennemis ! Modifiez 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, un calque au sol, des blocs au sol et le texte &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 Flame 2D et l'avez intégré dans un wrapper Flutter. Vous avez utilisé les effets de Flame pour animer et supprimer des composants. Vous avez utilisé Google Fonts et les packages Flutter Animate pour améliorer la conception du jeu.

Étape suivante

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

Documentation complémentaire