Erstelle ein 2D-Physikspiel mit Flutter & Flame

1. Hinweis

Flame ist eine Flutter-basierte 2D-Spiel-Engine. In diesem Codelab erstellen Sie ein Spiel mit einer 2D-Physiksimulation namens Forge2D entlang der Linien von Box2D. Sie verwenden die Komponenten von Flame, um die simulierte physische Realität auf dem Bildschirm darzustellen, damit Ihre Nutzer damit spielen können. Wenn Sie fertig sind, sollte Ihr Spiel wie dieses animierte GIF aussehen:

Animation des Spiels in diesem 2D-Physikspiel

Vorbereitung

Lerninhalte

  • Die Grundlagen von Forge2D, angefangen mit den verschiedenen Arten von physischen Körpern
  • Physische Simulation in 2D einrichten

Voraussetzungen

Compiler-Software für dein gewähltes Entwicklungsziel. Dieses Codelab funktioniert für alle sechs von Flutter unterstützten Plattformen. 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 der Einfachheit halber 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.
  1. Ä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.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.

Das flame-Paket ist Ihnen bekannt, aber für die anderen drei ist möglicherweise eine Erklärung erforderlich. Das Paket characters wird für die UTF8-konforme Bearbeitung von Dateipfaden verwendet. Das flame_forge2d-Paket stellt die Forge2D-Funktionalität so bereit, dass sie gut mit Flame funktioniert. Schließlich wird das Paket xml an verschiedenen Stellen zum Verarbeiten und Ändern von XML-Inhalten verwendet.

Ö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, der die FlameGame-Instanz instanziiert. In diesem Codelab gibt es keinen Flutter-Code, der den Status der Spielinstanz verwendet, um Informationen zum laufenden Spiel anzuzeigen. Daher funktioniert dieser vereinfachte Bootstrap problemlos.

Optional: Absolviere eine macOS-Nebenaufgabe

Die Screenshots in diesem Projekt stammen aus dem Spiel als macOS-Desktop-App. Damit die Titelleiste der App nicht von der allgemeinen Nutzung ablenkt, können Sie die Projektkonfiguration des macOS-Runners so ändern, dass die Titelleiste nicht mehr angezeigt wird.

Führen Sie dazu die folgenden Schritte aus:

  1. Erstellen Sie eine bin/modify_macos_config.dart-Datei und fügen Sie den 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 im Basisverzeichnis des Projekts das Tool wie folgt aus:
$ dart bin/modify_macos_config.dart

Wenn alles nach Plan verläuft, generiert das Programm keine Ausgabe in der Befehlszeile. Es wird jedoch die Konfigurationsdatei macos/Runner/Base.lproj/MainMenu.xib 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. Ein neues Fenster mit nur einem leeren schwarzen Hintergrund sollte 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 den Bildschirm spielerisch gestalten zu können. In diesem Codelab wird das Paket Physics Assets von Kenney.nl verwendet. Diese Assets sind mit Creative Commons CC0 lizenziert, ich empfehle aber dennoch dringend, dem Team von Kenney eine Spende zu schenken, damit es seine Arbeit fortsetzen kann. Hab ich doch.

Du musst die Konfigurationsdatei pubspec.yaml ändern, um die Verwendung von Kenneys Assets zu aktivieren. Ändern Sie sie wie folgt:

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 erwartet, dass sich Bild-Assets in assets/images befinden. Dies kann jedoch anders konfiguriert werden. Weitere Informationen finden Sie in der Dokumentation zu Images von Flame. Nachdem Sie die Pfade konfiguriert haben, müssen Sie sie dem Projekt selbst hinzufügen. Eine Möglichkeit dazu ist die Verwendung der Befehlszeile wie folgt:

$ mkdir -p assets/images

Der Befehl mkdir sollte keine Ausgabe erhalten, aber das neue Verzeichnis sollte entweder im Editor oder in einem Datei-Explorer sichtbar sein.

Maximieren Sie die heruntergeladene Datei kenney_physics-assets.zip. Sie sollten Folgendes sehen:

Eine Dateiliste des Kenny_physics-Assets-Pakets, wobei das Verzeichnis „PNG/Backgrounds“ hervorgehoben ist

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

Sprite Sheets sind ebenfalls vorhanden. Dabei handelt es sich um eine Kombination aus einem PNG-Bild und einer XML-Datei, die beschreibt, wo im Spritesheet-Bild kleinere Bilder zu finden sind. Sprite Sheets sind eine Technik, mit der die Ladezeit reduziert wird, indem nur eine einzelne Datei geladen wird. Im Gegensatz dazu werden Dutzende, wenn nicht Hunderte von einzelnen Bilddateien geladen.

Eine Dateiliste des Pakets „kenneny_physics-assets“, in dem das Verzeichnis „Spritesheet“ markiert ist

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

Eine Dateiliste des Projektverzeichnisses forge2d_game mit hervorgehobenem Asset-Verzeichnis

Hintergrund streichen

Nun, da Ihrem Projekt Bild-Assets hinzugefügt wurden, können Sie sie auf dem Bildschirm präsentieren. Nun, ein Bild auf dem Bildschirm. Weitere Informationen folgen in den nächsten Schritten.

Erstellen Sie eine Datei mit dem Namen background.dart in einem neuen Verzeichnis namens lib/components 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 ein spezielles SpriteComponent. Dort wird eines der vier Hintergrundbilder von Kenney.nl angezeigt. In diesem Code gibt es einige vereinfachende Annahmen. Zum einen sind die Bilder quadratisch, also alle vier Hintergrundbilder von Kenney. Das zweite ist, dass sich die Größe der sichtbaren Welt nie ändert, da diese Komponente sonst Ereignisse zur Größenanpassung des Spiels verarbeiten müsste. Die dritte Annahme ist, dass sich die Position (0,0) in der Mitte des Bildschirms befinden wird. Diese Annahmen erfordern eine spezifische Konfiguration der CameraComponent des Spiels.

Erstellen Sie eine weitere neue Datei mit dem Namen game.dart im Verzeichnis 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();
  }
}

Hier passiert viel. Beginnen wir mit dem Kurs MyPhysicsGame. Im Gegensatz zum vorherigen Codelab wird damit Forge2DGame und nicht FlameGame erweitert. Forge2DGame selbst erweitert FlameGame mit einigen interessanten Änderungen. Die erste ist, dass zoom standardmäßig auf „10“ gesetzt ist. Diese zoom-Einstellung bestimmt den Bereich nützlicher Werte, mit denen Box2D-Stil-Simulationssysteme für die Physik funktionieren. Der Motor ist im MKS-System geschrieben, wobei davon ausgegangen wird, dass für die Einheiten Meter, Kilogramm und Sekunden angegeben werden. Der Bereich, in dem du keine merklichen mathematischen Fehler für Objekte siehst, liegt zwischen 0,1 Metern und 10 Metern Entfernung. Die direkte Eingabe von Pixeldimensionen ohne Herunterskalierung würde Forge2D außerhalb seiner nützlichen Hülle entfalten. Die nützliche Zusammenfassung ist, dass man Objekte in der Reichweite einer Getränkedose bis hin zu einem Bus simuliert.

Die für die Hintergrundkomponente getroffenen Annahmen werden hier erfüllt, indem die Auflösung von CameraComponent auf 800 x 600 virtuelle Pixel festgelegt wird. Das bedeutet, dass der Spielbereich 80 Einheiten breit und 60 Einheiten hoch ist, zentriert bei (0,0). Dies hat zwar keine Auswirkungen auf die angezeigte Auflösung, wirkt sich aber darauf aus, wo die Objekte in der Spielszene platziert werden.

Neben dem Konstruktorargument camera befindet sich ein weiteres Argument mit dem Namen gravity, das stärker auf die Physik ausgerichtet ist. Die Schwerkraft ist auf Vector2 mit einem x von 0 und einem y von 10 festgelegt. Die 10 ist eine gute Annäherung an den allgemein akzeptierten Wert von 9,81 Metern pro Sekunde und Sekunde für die Schwerkraft. Die Tatsache, dass die Schwerkraft auf positiv 10 eingestellt ist, zeigt, dass in diesem System die Richtung für die Y-Achse nach unten ist. Dies unterscheidet sich im Allgemeinen von Box2D, entspricht aber der gewöhnlichen Konfiguration von Flame.

Als Nächstes folgt die Methode onLoad. Diese Methode ist asynchron. Sie eignet sich daher, weil damit Bild-Assets vom Laufwerk geladen werden. Die images.load-Aufrufe geben ein Future<Image> zurück und speichern das geladene Bild im Spielobjekt. Diese Future-Objekte werden zusammengefasst und als eine Einheit mit der statischen Methode Futures.wait gewartet. Die Liste der zurückgegebenen Bilder wird dann auf individuelle Namen abgestimmt.

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

Sie müssen nur noch ein paar kleinere Änderungen an lib/main.dart vornehmen, um ein Bild auf dem Bildschirm zu erhalten.

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

Mit dieser einfachen Änderung kannst du das Spiel jetzt noch einmal spielen, um den Hintergrund auf dem Bildschirm zu sehen. Die Kamerainstanz CameraComponent.withFixedResolution() fügt wie erforderlich Letterboxing-Balken hinzu, damit das Seitenverhältnis 800 x 600 des Spiels funktioniert.

Ein App-Fenster mit dem Hintergrundbild von grünen Hügeln und merkwürdigen abstrakten Bäumen.

4. Boden hinzufügen

Etwas, auf dem du aufbauen kannst

Wenn wir die Schwerkraft haben, brauchen wir etwas, um Objekte im Spiel zu fangen, bevor sie vom unteren Rand des Bildschirms herunterfallen. Natürlich, es sei denn, dass das Herunterfallen vom Bildschirm Teil deines Spieldesigns ist. 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 stammt von BodyComponent. In Forge2D sind Körper wichtig. Sie sind die Objekte, die Teil der zweidimensionalen physikalischen Simulation sind. Für die BodyDef für diese Komponente ist eine BodyType.static angegeben.

In Forge2D gibt es drei verschiedene Körpertypen. Statische Körper bewegen sich nicht. Tatsächlich haben sie eine Masse von Null – sie reagieren nicht auf die Schwerkraft – und eine unendliche Masse. Sie bewegen sich nicht, wenn sie von anderen Objekten getroffen werden, egal wie schwer sie sind. Statische Körper sind daher perfekt als Bodenoberfläche geeignet, da sie sich nicht bewegen.

Die anderen beiden Körpertypen sind kinematisch und dynamisch. Dynamische Körper sind Körper, die vollständig simuliert sind und auf die Schwerkraft und auf die Objekte, auf die sie stoßen, reagieren. Im Rest dieses Codelabs werden Sie viele dynamische Texte sehen. Kinematische Körper sind ein Zwischenraum zwischen statischen und dynamischen Körpern. Sie bewegen sich, reagieren aber nicht auf die Schwerkraft oder andere Objekte, die sie treffen. Nützlich, aber außerhalb des Rahmens dieses Codelabs.

Der Körper selbst tut nicht viel. Ein Körper benötigt Formen, damit er eine Substanz enthält. In diesem Fall ist diesem Text eine Form zugeordnet: PolygonShape, die als BoxXY festgelegt ist. Diese Art von Box ist an der Welt ausgerichtet, im Gegensatz zu einer PolygonShape, die als BoxXY festgelegt ist und um einen Drehpunkt gedreht werden kann. Das ist wieder nützlich, aber auch außerhalb des Rahmens dieses Codelab. Die Form und das Gehäuse sind über eine Halterung miteinander verbunden. Dies ist nützlich, um dem System Elemente wie friction hinzuzufügen.

Standardmäßig werden die angehängten Formen von einem Textkörper so gerendert, dass dies für die Fehlerbehebung hilfreich ist, aber nicht für ein tolles Gameplay geeignet ist. Wenn du das super-Argument renderBody auf false festlegst, wird dieses Debug-Rendering deaktiviert. Für die Bereitstellung eines In-Game-Renderings für diesen Text ist das untergeordnete SpriteComponent-Element verantwortlich.

Wenn du dem Spiel die Komponente „Ground“ hinzufügen möchtest, bearbeite deine Datei „game.dart“ 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 wird der Welt eine Reihe von Ground-Komponenten hinzugefügt. Dazu wird eine for-Schleife innerhalb eines List-Kontexts verwendet und die resultierende Liste von Ground-Komponenten wird an die addAll-Methode von world übergeben.

Beim Laufen werden jetzt der Hintergrund und der Boden angezeigt.

Ein Anwendungsfenster mit Hintergrund und einer Bodenebene.

5. Bausteine hinzufügen

Mauer erstellen

Der Boden lieferte uns ein Beispiel für einen statischen Körper. Jetzt ist es Zeit für Ihre erste dynamische Komponente. Dynamische Komponenten in Forge2D sind der Eckpfeiler für das Spielerlebnis. Sie sind die Dinge, die sich bewegen und mit der Welt um sich herum interagieren. In diesem Schritt setzt du Bausteine ein, die nach dem Zufallsprinzip in einer Reihe von Bausteinen auf dem Bildschirm erscheinen. Ihr werdet sehen, wie sie sich hin- und herbewegen und dabei aneinanderstoßen.

Aus den Elementen-Sprite-Sheets werden Ziegel erstellt. Wenn Sie sich die Sprite-Sheet-Beschreibung auf assets/spritesheet_elements.xml ansehen, werden Sie feststellen, dass ein interessantes Problem vorliegt. Die Namen scheinen nicht sehr hilfreich zu sein. Nützlich wäre die Auswahl eines Ziegels nach Materialtyp, Größe und Art des Schadens. Glücklicherweise verbrachte ein hilfsbereiter Wichtel etwas Zeit, um das Muster in der Dateibenennung zu ermitteln, und entwickelte ein Tool, das es allen einfacher machte. Erstellen Sie im Verzeichnis bin eine neue Datei generate_brick_file_names.dart und fügen Sie den 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();
}

Im Editor sollte eine Warnung oder Fehlermeldung zu einer fehlenden Abhängigkeit angezeigt werden. Fügen Sie es wie folgt 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 Sprite Sheet-Beschreibungsdatei geparst und in Dart-Code konvertiert, damit wir die richtige Bilddatei für jeden Baustein auswählen können, den Sie auf dem Bildschirm platzieren möchten. Hilfreich!

Erstellen Sie die Datei brick.dart 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 können jetzt sehen, wie der oben generierte Dart-Code in diese Codebasis integriert wurde, um eine schnelle und einfache Auswahl von Ziegel-Images nach Material, Größe und Zustand zu erleichtern. Wenn Sie über die enum-Elemente bis zur Brick-Komponente selbst schauen, sollten Sie feststellen, dass der größte Teil dieses Codes Ihnen von der Ground-Komponente aus dem vorherigen Schritt ziemlich vertraut ist. Hier gibt es einen änderbaren Zustand, der eine Beschädigung des Bausteins ermöglicht, obwohl dies dem Lesenden als Übung überlassen bleibt.

Zeit, die Bausteine auf dem Bildschirm zu zeigen. 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.
}

Diese Codeergänzung unterscheidet sich etwas von dem Code, mit dem Sie die Ground-Komponenten hinzugefügt haben. Diesmal werden die Bricks im Laufe der Zeit in einem zufälligen Cluster hinzugefügt. Dies besteht aus zwei Teilen. Der erste besteht darin, dass die Methode, die die Brick-awaits zu einem Future.delayed hinzufügt, was das asynchrone Äquivalent eines sleep()-Aufrufs ist. Es gibt jedoch noch einen zweiten Teil für die Umsetzung: Der Aufruf von addBricks in der Methode onLoad wird nicht mit await ausgeführt. Andernfalls würde die onLoad-Methode erst abgeschlossen werden, wenn alle Bausteine auf dem Bildschirm vorhanden waren. Wenn der Aufruf von addBricks in einen unawaited-Aufruf eingebunden wird, sind die Linters zufrieden und unsere Absicht wird für zukünftige Programmierer deutlich. Es ist beabsichtigt, nicht darauf zu warten, dass diese Methode zurückgegeben wird.

Wenn du das Spiel läufst, erscheinen Bausteine, die ineinander prallen und auf den Boden fallen.

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

6. Spieler hinzufügen

Wirf Aliens auf Steine

Die ersten Male macht Spaß, wenn Steine herumrollen. Aber dieses Spiel wird vermutlich mehr Spaß machen, wenn wir dem Spieler einen Avatar geben, mit dem er mit der Welt interagieren kann. Wie wäre es mit einem Außerirdischen, der auf die Steine schlagen kann?

Erstellen Sie im Verzeichnis lib/components eine neue Datei player.dart 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.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

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

Dies ist eine weitere Stufe gegenüber den Brick-Komponenten im vorherigen Schritt. Diese Player-Komponente hat zwei untergeordnete Komponenten: eine SpriteComponent, die du erkennen solltest, und eine CustomPainterComponent, die neu ist. Das Konzept CustomPainter stammt von Flutter und ermöglicht Ihnen, auf einer Leinwand zu malen. Hier wird dem Spieler mitgeteilt, wohin der runde Alien fliegen wird, wenn er geschleudert wird.

Wie initiiert der Spieler den Außerirdischen? Mithilfe einer Ziehbewegung, die die Player-Komponente mit den DragCallbacks-Callbacks erkennt Der Adleraugen unter euch wird hier noch etwas anderes bemerkt haben.

Während Ground-Komponenten statische Körper waren, waren die Ziegel-Komponenten dynamische Körper. Der Player ist eine Kombination aus beidem. Der Player startet zunächst statisch und wartet darauf, dass der Außerirdische ihn ziehen kann. Wenn er loslässt, ändert er sich von statisch in dynamisch, fügt im Verhältnis zum Ziehen einen linearen Impuls hinzu und lässt den Alien-Avatar fliegen.

In der Player-Komponente ist auch Code enthalten, mit dem sie vom Bildschirm entfernt wird, wenn sie außerhalb des festgelegten Bereichs liegt, einschlaft oder eine Zeitüberschreitung auftritt. Die Absicht besteht darin, dem Spieler zu ermöglichen, den Alien zu werfen, zu sehen, was passiert, und es dann noch einmal versuchen zu können.

Du kannst die Player-Komponente in das Spiel integrieren, indem du game.dart so bearbeitest:

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 den vorherigen Komponenten, mit einer zusätzlichen Falte. Der Außerirdische des Spielers ist so konzipiert, dass er sich unter bestimmten Bedingungen aus dem Spiel entfernt. Deshalb gibt es hier einen Update-Handler, der prüft, ob im Spiel keine Player-Komponente vorhanden ist. Falls ja, wird eine wieder hinzugefügt. So sieht das Spiel aus.

Ein App-Fenster mit grünen Hügeln im Hintergrund, Bodenebene, Blöcken auf dem Boden und einem Spieleravatar im Flug

7. Auf Auswirkungen reagieren

Feinde hinzufügen

Sie haben gesehen, wie statische und dynamische Objekte miteinander interagieren. Um jedoch wirklich zu erreichen, benötigen Sie Callbacks im Code, wenn etwas kollidiert. Sehen wir uns an, wie das funktioniert. Sie bringen Feinde auf, gegen die sich die Spieler bezwingen können. So hast du den Weg zu einer Siegerbedingung – entferne alle Feinde aus dem Spiel!

Erstellen Sie im Verzeichnis lib/components eine enemy.dart-Datei 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 bekannt sein. Aufgrund einer neuen unbekannten Basisklasse werden jedoch im Editor einige rot unterstrichene Elemente angezeigt. Fügen Sie diese Klasse jetzt hinzu, indem Sie lib/components eine Datei namens body_component_with_user_data.dart mit folgendem 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 bildet in Kombination mit dem neuen beginContact-Callback in der Enemy-Komponente die Grundlage, um programmatisch über Auswirkungen zwischen Körpern benachrichtigt zu werden. Tatsächlich müssen Sie alle Komponenten bearbeiten, zwischen denen Sie Benachrichtigungen zu Auswirkungen erhalten möchten. Fahren Sie fort und bearbeiten Sie die Komponenten Brick, Ground und Player, um diese BodyComponentWithUserData anstelle der BodyComponent-Basisklasse zu verwenden, die diese Komponenten derzeit 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 der Kontaktaufnahme umgeht, findest du in der Forge2D-Dokumentation zu Kontaktrückrufen.

Das Spiel gewinnen

Jetzt, da du Feinde hast und die Feinde von der Welt vertrieben hast, gibt es eine einfache Möglichkeit, diese Simulation in ein Spiel zu verwandeln. Setze dir das Ziel, alle Feinde zu beseitigen! 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 Herausforderung besteht darin, das Spiel zu starten und zu diesem Bildschirm zu gelangen, falls du sie annehmen möchtest.

Ein App-Fenster mit grünen Hügeln im Hintergrund, Bodenebene, Blöcken auf dem Boden und einem Text-Overlay mit dem Text „Sie haben gewonnen!“

8. Glückwunsch

Herzlichen Glückwunsch! Du hast mit Flutter and Flame ein Spiel entwickelt.

Sie haben ein Spiel mit der Flame-2D-Spiel-Engine erstellt und in einen Flutter-Wrapper eingebettet. Sie haben mit „Flame's Effects“ Komponenten animiert und entfernt. 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