Erstelle ein 2D-Physikspiel mit Flutter & Flame

2D-Physikspiel mit Flutter und Flame erstellen

Informationen zu diesem Codelab

subjectZuletzt aktualisiert: Juni 23, 2025
account_circleVerfasst von Brett Morgan

1. Hinweis

Flame ist eine Flutter-basierte 2D-Spiel-Engine. In diesem Codelab erstellen Sie ein Spiel, das eine 2D-Physiksimulation ähnlich Box2D namens Forge2D verwendet. Mit den Komponenten von Flame können Sie die simulierte physische Realität auf dem Bildschirm darstellen, damit Ihre Nutzer damit spielen können. Ihr Spiel sollte dann in etwa so aussehen wie dieses animierte GIF:

Animation des Gameplays dieses 2D-Physikspiels

Vorbereitung

Lerninhalte

  • Die Grundlagen von Forge2D, beginnend mit den verschiedenen Arten von physischen Körpern.
  • So richten Sie eine physikalische Simulation in 2D ein.

Voraussetzungen

Compilersoftware für das ausgewählte Entwicklungsziel. Dieses Codelab funktioniert auf allen sechs Plattformen, die von Flutter unterstützt werden. Sie benötigen Visual Studio für Windows, Xcode für macOS oder iOS und Android Studio für Android.

2. Projekt erstellen

Flutter-Projekt erstellen

Es gibt viele Möglichkeiten, ein Flutter-Projekt zu erstellen. In diesem Abschnitt verwenden Sie aus Gründen der Übersichtlichkeit die Befehlszeile.

Führen Sie zunächst die folgenden Schritte aus:

  1. Erstellen Sie in einer Befehlszeile ein Flutter-Projekt:
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. Ändern Sie die Abhängigkeiten des Projekts, um Flame und Forge2D hinzuzufügen:
    $ 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.
    

Das Paket flame ist Ihnen bereits bekannt, die anderen drei erfordern jedoch möglicherweise eine Erklärung. Das Paket characters wird für die UTF-8-konforme Pfadmanipulation verwendet. Das flame_forge2d-Paket stellt die Forge2D-Funktionen so bereit, dass sie gut mit Flame funktionieren. Schließlich wird das xml-Paket an verschiedenen Stellen verwendet, um XML-Inhalte zu verarbeiten und zu ändern.

Öffnen Sie das Projekt und ersetzen Sie den Inhalt der Datei lib/main.dart durch Folgendes:

lib/main.dart

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

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

Dadurch wird die App mit einem GameWidget gestartet, das die Instanz FlameGame instanziiert. In diesem Codelab gibt es keinen Flutter-Code, der den Status der Spielinstanz verwendet, um Informationen zum laufenden Spiel anzuzeigen. Daher funktioniert diese vereinfachte Bootstrap-Methode gut.

Optional: macOS-exklusive Nebenquest absolvieren

Die Screenshots in diesem Projekt stammen aus dem Spiel als macOS-Desktop-App. Damit die Titelleiste der App nicht das Gesamtbild beeinträchtigt, können Sie die Projektkonfiguration des macOS-Runners so ändern, dass die Titelleiste ausgeblendet wird.

Führen Sie dazu die folgenden Schritte aus:

  1. Erstellen Sie eine bin/modify_macos_config.dart-Datei und fügen Sie ihr folgenden Inhalt hinzu:

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());
}

Diese Datei befindet sich nicht im Verzeichnis lib, da sie nicht Teil der Laufzeit-Codebasis für das Spiel ist. Es ist ein Befehlszeilentool zum Ändern des Projekts.

  1. Führen Sie das Tool so aus:
dart bin/modify_macos_config.dart

Wenn alles nach Plan verläuft, generiert das Programm keine Ausgabe in der Befehlszeile. Die macos/Runner/Base.lproj/MainMenu.xib-Konfigurationsdatei wird jedoch so geändert, dass das Spiel ohne sichtbare Titelleiste ausgeführt wird und das Flame-Spiel das gesamte Fenster einnimmt.

Starte das Spiel, um zu prüfen, ob alles funktioniert. Es sollte ein neues Fenster mit einem leeren schwarzen Hintergrund angezeigt werden.

Ein App-Fenster mit schwarzem Hintergrund und nichts im Vordergrund

3. Bild-Assets hinzufügen

Bilder hinzufügen

Jedes Spiel benötigt Art-Assets, um einen Bildschirm so zu gestalten, dass es Spaß macht. In diesem Codelab wird das Paket Physics Assets von Kenney.nl verwendet. Diese Assets sind unter der Creative Commons CC0-Lizenz lizenziert. Ich empfehle Ihnen aber trotzdem, dem Team von Kenney eine Spende zu geben, damit es seine tolle Arbeit fortsetzen kann. Hab ich doch.

Sie müssen die Konfigurationsdatei pubspec.yaml ändern, um die Verwendung der Assets von Kenney zu ermöglichen. Ändern Sie sie so:

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.

In Flame werden Bild-Assets in assets/images erwartet, dies kann jedoch anders konfiguriert werden. Weitere Informationen finden Sie in der Dokumentation zu Bildern in Flame. Nachdem Sie die Pfade konfiguriert haben, müssen Sie sie dem Projekt selbst hinzufügen. Eine Möglichkeit hierfür bietet die Befehlszeile:

mkdir -p assets/images

Es sollte keine Ausgabe vom Befehl mkdir geben, aber das neue Verzeichnis sollte entweder in Ihrem Editor oder in einem Dateimanager sichtbar sein.

Maximieren Sie die heruntergeladene kenney_physics-assets.zip-Datei. Sie sollte in etwa so aussehen:

Ein Dateiverzeichnis des Pakets „kenney_physics-assets“ in maximierter Ansicht, wobei das Verzeichnis „PNG/Backgrounds“ hervorgehoben ist

Kopieren Sie die Dateien colored_desert.png, colored_grass.png, colored_land.png und colored_shroom.png aus dem Verzeichnis PNG/Backgrounds in das Verzeichnis assets/images Ihres Projekts.

Es gibt auch Sprite-Sheets. Sie bestehen aus einer PNG-Bilddatei und einer XML-Datei, die beschreibt, wo im Sprite-Sheet-Bild kleinere Bilder zu finden sind. Sprite-Sheets sind eine Methode, um die Ladezeit zu verkürzen, indem nur eine einzige Datei geladen wird, anstatt Dutzende oder gar Hunderte einzelner Bilddateien.

Ein Dateiverzeichnis des Pakets „kenney_physics-assets“ in maximierter Ansicht, wobei das Verzeichnis „Spritesheet“ hervorgehoben ist

Kopieren Sie spritesheet_aliens.png, spritesheet_elements.png und spritesheet_tiles.png in das Verzeichnis assets/images Ihres Projekts. Kopieren Sie auch die Dateien spritesheet_aliens.xml, spritesheet_elements.xml und spritesheet_tiles.xml in das Verzeichnis assets Ihres Projekts. Ihr Projekt sollte in etwa so aussehen:

Ein Dateiverzeichnis des Projektverzeichnisses „forge2d_game“, in dem das Verzeichnis „assets“ hervorgehoben ist

Hintergrund malen

Nachdem Sie Ihrem Projekt Bild-Assets hinzugefügt haben, ist es an der Zeit, sie auf dem Bildschirm zu platzieren. Nun, ein Bild auf dem Bildschirm. Weitere Informationen folgen in den nächsten Schritten.

Erstellen Sie im neuen Verzeichnis lib/components eine Datei namens background.dart und fügen Sie den folgenden Inhalt hinzu.

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,
     
),
   
);
 
}
}

Diese Komponente ist eine spezielle SpriteComponent. Es ist für die Anzeige eines der vier Hintergrundbilder von Kenney.nl verantwortlich. In diesem Code werden einige Vereinfachungen vorgenommen. Erstens: Die Bilder sind quadratisch. Das trifft auf alle vier Hintergrundbilder von Kenney zu. Zweitens: Die Größe der sichtbaren Welt ändert sich nie. Andernfalls müsste diese Komponente Ereignisse für die Größe des Spiels verarbeiten. Die dritte Annahme ist, dass sich die Position (0,0) in der Mitte des Bildschirms befindet. Diese Annahmen erfordern eine spezielle Konfiguration der CameraComponent des Spiels.

Erstellen Sie im Verzeichnis lib/components eine weitere neue Datei mit dem Namen game.dart.

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();
 
}
}

Hier passiert viel. Beginnen Sie mit der Klasse MyPhysicsGame. Im Gegensatz zum vorherigen Codelab wird hier Forge2DGame und nicht FlameGame erweitert. Forge2DGame erweitert FlameGame um einige interessante Funktionen. Erstens: zoom ist standardmäßig auf 10 festgelegt. Diese zoom-Einstellung bezieht sich auf den Bereich nützlicher Werte, mit denen Physiksimulations-Engines im Box2D-Stil gut funktionieren. Der Engine-Code wird im MKS-System geschrieben, bei dem die Einheiten in Metern, Kilogramm und Sekunden angegeben werden. Der Bereich, in dem keine merklichen mathematischen Fehler bei Objekten auftreten, liegt zwischen 0,1 Metern und mehreren Dutzend Metern. Wenn Sie die Pixelabmessungen direkt ohne eine gewisse Skalierung eingeben, würde Forge2D seine Grenzen überschreiten. Zur Veranschaulichung können Sie sich vorstellen, Objekte im Bereich von einer Getränkedose bis zu einem Bus zu simulieren.

Die in der Hintergrundkomponente getroffenen Annahmen werden hier erfüllt, indem die Auflösung von CameraComponent auf 800 × 600 virtuelle Pixel festgelegt wird. Das Spielfeld ist also 80 Einheiten breit und 60 Einheiten hoch und zentriert auf (0,0). Dies hat keine Auswirkungen auf die angezeigte Auflösung, aber darauf, wo wir Objekte in der Spielszene platzieren.

Neben dem Konstruktorargument camera gibt es ein weiteres, physikalisch korrekteres Argument namens gravity. Die Schwerkraft ist auf eine Vector2 mit einer x von 0 und einer y von 10 festgelegt. 10 ist eine gute Näherung an den allgemein akzeptierten Wert von 9,81 Metern pro Sekunde pro Sekunde für die Schwerkraft. Die Tatsache, dass die Schwerkraft auf + 10 festgelegt ist, zeigt, dass in diesem System die Richtung der Y-Achse nach unten ist. Das unterscheidet sich zwar von Box2D im Allgemeinen, entspricht aber der Standardkonfiguration von Flame.

Als Nächstes folgt die onLoad-Methode. Diese Methode ist asynchron, was angemessen ist, da sie für das Laden von Bild-Assets von der Festplatte verantwortlich ist. Die Aufrufe von images.load geben ein Future<Image> zurück und als Nebeneffekt wird das geladene Bild im Game-Objekt im Cache gespeichert. Diese Futures werden mithilfe der statischen Methode Futures.wait zusammengeführt und als einzelne Einheit erwartet. Die Liste der zurückgegebenen Bilder wird dann anhand von Mustern in einzelne Namen abgeglichen.

Die Spritesheet-Bilder werden dann in eine Reihe von XmlSpriteSheet-Objekten eingespeist, die für das Abrufen der im Spritesheet enthaltenen einzelnen Sprites verantwortlich sind. Die Klasse XmlSpriteSheet ist im Paket flame_kenney_xml definiert.

Jetzt müssen Sie nur noch ein paar kleinere Änderungen an lib/main.dart vornehmen, damit ein Bild auf dem Bildschirm angezeigt wird.

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
}

Durch diese Änderung können Sie das Spiel jetzt noch einmal ausführen, um den Hintergrund auf dem Bildschirm zu sehen. Hinweis: Die CameraComponent.withFixedResolution()-Kamerainstanz fügt bei Bedarf Letterboxing hinzu, damit das Seitenverhältnis von 800 × 600 Pixeln des Spiels funktioniert.

Eine App mit grünen Hügeln und seltsam abstrakten Bäumen.

4. Boden hinzufügen

Ein solides Fundament

Wenn wir eine Schwerkraft haben, brauchen wir etwas, das Objekte im Spiel auffängt, bevor sie unten vom Bildschirm fallen. Es sei denn, das Verlassen des Bildschirms ist Teil Ihres Gamedesigns. Erstellen Sie eine neue ground.dart-Datei in Ihrem lib/components-Verzeichnis und fügen Sie Folgendes hinzu:

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),
         
),
       
],
     
);
}

Diese Ground-Komponente leitet sich von BodyComponent ab. In Forge2D sind Körper wichtig. Sie sind die Objekte, die Teil der zweidimensionalen physikalischen Simulation sind. Für die BodyDef dieser Komponente ist eine BodyType.static angegeben.

In Forge2D gibt es drei verschiedene Körpertypen. Statische Körper bewegen sich nicht. Sie haben praktisch sowohl eine Nullmasse – sie reagieren nicht auf die Schwerkraft – als auch eine unendliche Masse – sie bewegen sich nicht, wenn sie von anderen Objekten getroffen werden, unabhängig davon, wie schwer sie sind. Statisch platzierte Körper eignen sich daher perfekt für eine Bodenoberfläche, da sie sich nicht bewegen.

Die anderen beiden Körpertypen sind kinematische und dynamische Körper. Dynamische Körper sind Körper, die vollständig simuliert werden. Sie reagieren auf die Schwerkraft und auf die Objekte, mit denen sie zusammenstoßen. Im Rest dieses Codelabs sehen Sie viele dynamische Körper. Kinematische Körper sind eine Zwischenstufe zwischen statisch und dynamisch. Sie bewegen sich, reagieren aber nicht auf die Schwerkraft oder andere Objekte, die sie treffen. Nützlich, aber nicht Teil dieses Codelabs.

Der Body selbst hat nicht viel zu tun. Ein Körper braucht zugehörige Formen, um Substanz zu haben. In diesem Fall ist diesem Körper eine Form zugewiesen, eine PolygonShape, die als BoxXY festgelegt ist. Dieser Boxtyp ist achsorientiert, im Gegensatz zu einem PolygonShape, das als BoxXY festgelegt ist und um einen Drehpunkt gedreht werden kann. Auch das ist nützlich, aber es fällt nicht in den Rahmen dieses Codelabs. Die Form und der Körper sind mit einer Halterung verbunden, was nützlich ist, um dem System Dinge wie friction hinzuzufügen.

Standardmäßig werden die angehängten Formen eines Körpers so gerendert, dass sie für das Debuggen nützlich sind, aber nicht für ein gutes Gameplay sorgen. Wenn Sie das super-Argument renderBody auf false setzen, wird dieses Debug-Rendering deaktiviert. Das In-Game-Rendering dieses Körpers liegt in der Verantwortung des Kindes SpriteComponent.

Wenn Sie dem Spiel die Ground-Komponente hinzufügen möchten, bearbeiten Sie die game.dart-Datei so:

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.
}

Mit dieser Änderung werden der Welt eine Reihe von Ground-Komponenten hinzugefügt. Dazu wird eine for-Schleife in einem List-Kontext verwendet und die resultierende Liste der Ground-Komponenten an die addAll-Methode von world übergeben.

Wenn Sie das Spiel jetzt starten, werden der Hintergrund und der Boden angezeigt.

Ein Anwendungsfenster mit Hintergrund und einer Bodenschicht.

5. Ziegel hinzufügen

Mauer erstellen

Der Boden ist ein Beispiel für einen statischen Körper. Jetzt ist es an der Zeit für Ihre erste dynamische Komponente. Dynamische Komponenten in Forge2D sind der Eckpfeiler der Nutzererfahrung. Sie sind die Elemente, die sich bewegen und mit der Welt um sie herum interagieren. In diesem Schritt fügen Sie Steine hinzu, die zufällig ausgewählt und in einem Cluster von Steinen auf dem Bildschirm angezeigt werden. Sie sehen, wie sie dabei fallen und gegeneinander stoßen.

Die Blöcke werden aus dem Sprite Sheet der Elemente erstellt. Wenn Sie sich die Beschreibung des Sprite-Sheets in assets/spritesheet_elements.xml ansehen, sehen Sie, dass wir ein interessantes Problem haben. Die Namen sind nicht sehr hilfreich. Es wäre nützlich, einen Ziegel nach Material, Größe und Beschädigung auswählen zu können. Glücklicherweise hat ein hilfreicher Elf einige Zeit damit verbracht, das Muster in der Dateibenennung zu ermitteln, und ein Tool erstellt, das euch die Arbeit erleichtert. Erstellen Sie im Verzeichnis bin eine neue Datei generate_brick_file_names.dart und fügen Sie ihr folgenden Inhalt hinzu:

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();
}

Ihr Editor sollte Sie über eine fehlende Abhängigkeit warnen oder einen Fehler anzeigen. Fügen Sie es mit dem folgenden Befehl hinzu:

flutter pub add equatable

Sie sollten dieses Programm jetzt so ausführen können:

$ 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',
      },
  };
}

Dieses Tool hat die Beschreibungsdatei des Sprite-Sheets geparst und in Dart-Code umgewandelt, mit dem wir die richtige Bilddatei für jeden Stein auswählen können, den Sie auf dem Bildschirm anzeigen möchten. Sehr hilfreich!

Erstellen Sie die brick.dart-Datei mit folgendem Inhalt:

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();
 
}
}

Sie sehen jetzt, wie der zuvor generierte Dart-Code in diese Codebasis eingebunden ist, damit Sie Ziegelbilder schnell nach Material, Größe und Zustand auswählen können. Wenn Sie über die enums hinweg auf die Brick-Komponente selbst schauen, sollten Sie feststellen, dass der Großteil dieses Codes der Ground-Komponente im vorherigen Schritt ziemlich ähnlich ist. Es gibt einen veränderbaren Zustand, der es ermöglicht, dass der Ziegel beschädigt wird. Die Verwendung dieses Zustands bleibt dem Leser als Übung überlassen.

Jetzt ist es an der Zeit, die Blöcke auf dem Bildschirm zu platzieren. Bearbeiten Sie die Datei game.dartso:

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.
}

Dieser Code unterscheidet sich etwas von dem Code, mit dem Sie die Ground-Komponenten hinzugefügt haben. Dieses Mal werden die Bricks im Laufe der Zeit einem zufälligen Cluster hinzugefügt. Dazu gehören zwei Teile: Erstens wird der Bricks awaits ein Future.delayed hinzugefügt, das asynchrone Äquivalent eines sleep()-Aufrufs. Es gibt jedoch noch einen zweiten Teil, der dafür sorgt, dass dies funktioniert: Der Aufruf von addBricks in der onLoad-Methode wird nicht awaited. Andernfalls wird die onLoad-Methode erst abgeschlossen, wenn alle Blöcke auf dem Bildschirm sind. Wenn wir den Aufruf von addBricks in einen unawaited-Aufruf einschließen, sind die Linter zufrieden und unsere Absicht für zukünftige Programmierer offensichtlich. Es ist beabsichtigt, nicht auf die Rückgabe dieser Methode zu warten.

Wenn Sie das Spiel starten, sehen Sie, wie Steine erscheinen, gegeneinander stoßen und auf dem Boden verstreut werden.

Ein App-Fenster mit grünen Hügeln im Hintergrund, einer Bodenschicht und Blöcken, die auf dem Boden landen.

6. Spieler hinzufügen

Aliens auf Ziegel werfen

Das Zusehen, wie die Blöcke zusammenfallen, macht in den ersten paar Malen Spaß, aber ich denke, dieses Spiel wird noch mehr Spaß machen, wenn wir dem Spieler einen Avatar geben, mit dem er mit der Welt interagieren kann. Wie wäre es mit einem Alien, das sie auf die Steine werfen können?

Erstellen Sie im Verzeichnis lib/components eine neue player.dart-Datei und fügen Sie Folgendes hinzu:

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;
}

Das ist ein Schritt nach oben im Vergleich zu den Brick-Komponenten im vorherigen Schritt. Diese Player-Komponente hat zwei untergeordnete Komponenten: eine SpriteComponent, die Ihnen bekannt sein sollte, und eine CustomPainterComponent, die neu ist. Das CustomPainter-Konzept stammt aus Flutter und ermöglicht es, auf einer Leinwand zu malen. Hier wird es verwendet, um dem Spieler Feedback dazu zu geben, wohin das runde Alien fliegt, wenn es geworfen wird.

Wie startet der Spieler das Werfen des Aliens? Durch eine Ziehgeste, die die Player-Komponente mit den DragCallbacks-Callbacks erkennt. Die aufmerksamen unter euch haben hier vielleicht noch etwas anderes bemerkt.

Während Ground-Komponenten statische Körper waren, waren die Brick-Komponenten dynamische Körper. Der Player hier ist eine Kombination aus beiden. Der Spieler ist zu Beginn statisch und wartet darauf, dass der Nutzer ihn zieht. Wenn der Nutzer loslässt, wechselt er von statisch zu dynamisch, fügt einen linearen Impuls proportional zum Ziehen hinzu und lässt den Alien-Avatar fliegen.

In der Player-Komponente gibt es auch Code, der das Element vom Bildschirm entfernt, wenn es den Bereich verlässt, in den Ruhemodus wechselt oder das Zeitlimit überschritten wird. Der Spieler soll das Alien schleudern, sehen, was passiert, und dann noch einmal versuchen.

Integrieren Sie die Player-Komponente in das Spiel, indem Sie game.dart so bearbeiten:

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.
}

Das Hinzufügen des Spielers zum Spiel ähnelt dem der vorherigen Komponenten, mit einer kleinen Ausnahme. Das Alien des Spielers soll sich unter bestimmten Bedingungen aus dem Spiel entfernen. Daher gibt es hier einen Update-Handler, der prüft, ob im Spiel keine Player-Komponente vorhanden ist. Ist das der Fall, wird eine hinzugefügt. Das Spiel sieht so aus:

Ein App-Fenster mit grünen Hügeln im Hintergrund, einer Bodenschicht, Blöcken auf dem Boden und einem fliegenden Spieleravatar.

7. Auf Auswirkungen reagieren

Gegner hinzufügen

Sie haben gesehen, wie statische und dynamische Objekte miteinander interagieren. Wenn Sie jedoch wirklich etwas erreichen möchten, müssen Sie Callbacks im Code erhalten, wenn Objekte kollidieren. Sie werden einige Feinde einführen, gegen die der Spieler antreten muss. Dies führt zu einer Gewinnbedingung: Entfernen Sie alle Feinde aus dem Spiel.

Erstellen Sie eine enemy.dart-Datei im Verzeichnis lib/components und fügen Sie Folgendes hinzu:

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();
}

Aus Ihren bisherigen Interaktionen mit den Player- und Brick-Komponenten sollte Ihnen der Großteil dieser Datei bereits bekannt sein. Aufgrund einer neuen unbekannten Basisklasse werden jedoch einige rote Unterstreichungen im Editor angezeigt. Fügen Sie diese Klasse jetzt hinzu, indem Sie lib/components die Datei body_component_with_user_data.dart mit dem folgenden Inhalt hinzufügen:

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;
 
}
}

Diese Basisklasse in Kombination mit dem neuen beginContact-Callback in der Enemy-Komponente bildet die Grundlage für programmatische Benachrichtigungen über Kollisionen zwischen Körpern. Sie müssen alle Komponenten bearbeiten, für die Sie Benachrichtigungen zu Auswirkungen erhalten möchten. Bearbeiten Sie die Komponenten Brick, Ground und Player, um diese BodyComponentWithUserData anstelle der Basisklasse BodyComponent zu verwenden, die diese Komponenten verwenden. So bearbeiten Sie beispielsweise die Komponente 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),
         
),
       
],
     
);
}

Weitere Informationen dazu, wie Forge2D mit Kontakten umgeht, findest du in der Forge2D-Dokumentation zu Kontakt-Callbacks.

Spiel gewinnen

Jetzt, da Sie Feinde haben und eine Möglichkeit, sie aus der Welt zu entfernen, können Sie diese Simulation ganz einfach in ein Spiel verwandeln. Dein Ziel sollte es sein, alle Feinde zu entfernen. Bearbeiten Sie die Datei game.dart so:

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.
 
}
}

Deine Mission: Starte das Spiel und rufe diesen Bildschirm auf.

Ein App-Fenster mit grünen Hügeln im Hintergrund, einer Bodenschicht, Blöcken auf dem Boden und dem Text-Overlay „Du hast gewonnen!“

8. Glückwunsch

Herzlichen Glückwunsch, Sie haben ein Spiel mit Flutter und Flame erstellt.

Sie haben ein Spiel mit der Flame-2D-Spiel-Engine erstellt und in einen Flutter-Wrapper eingebettet. Sie haben die Effekte von Flame verwendet, um Komponenten zu animieren und zu entfernen. Sie haben Google Fonts und Flutter Animate-Pakete verwendet, um das gesamte Spiel ansprechend zu gestalten.

Nächste Schritte

Sehen Sie sich einige dieser Codelabs an:

Weitere Informationen