À propos de cet atelier de programmation
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é:
Prérequis
- Vous avez terminé l'atelier de programmation Présentation de Flame avec Flutter.
Objectifs
- 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
- Le SDK Flutter
- Visual Studio Code (VS Code) avec les plug-ins Flutter et Dart
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 :
- 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.
- 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 :
- 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.
- À 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.
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:
À 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.
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.
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.
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.
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 await
s des Brick
s à 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.
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.
7. Réagir à l'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.
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...
- Créer des interfaces utilisateur de nouvelle génération dans Flutter
- Mettre en valeur son application Flutter
- Ajouter des achats via l'application à votre application Flutter