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é:
Prérequis
- Vous avez terminé l'atelier de programmation Présentation de Flame avec Flutter.
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
- SDK Flutter
- Visual Studio Code (VS Code) avec les plug-ins Flutter et Dart
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 :
- 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.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 :
- 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.
- À 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.
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:
À 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.
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.
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.
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.
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.
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.
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.
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...
- 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