Einführung in Flame mit Flutter

1. Einführung

Flame ist eine auf Flutter basierende 2D-Spiel-Engine. In diesem Codelab entwickeln Sie ein Spiel, das von einem der Klassiker der Videospiele der 1970er-Jahre inspiriert ist: Breakout von Steve Wozniak. Sie verwenden die Komponenten von Flame, um die Fledermaus, den Ball und die Ziegel zu zeichnen. Sie verwenden die Effekte von Flame, um die Bewegung der Fledermaus zu animieren, und sehen, wie Sie Flame in das Statusverwaltungssystem von Flutter einbinden.

Wenn Sie fertig sind, sollte Ihr Spiel wie in diesem animierten GIF aussehen, nur etwas langsamer.

Eine Bildschirmaufzeichnung eines Spiels. Das Spiel wurde deutlich beschleunigt.

Lerninhalte

  • Grundlagen von Flame, beginnend mit GameWidget.
  • So verwenden Sie einen Game-Loop.
  • So funktionieren die Components von Flame. Sie ähneln den Widgets von Flutter.
  • Umgang mit Kollisionen
  • Effects zum Animieren von Components verwenden
  • So legen Sie Flutter-Widgets über ein Flame-Spiel.
  • So binden Sie Flame in die Statusverwaltung von Flutter ein.

Aufgaben

In diesem Codelab erstellen Sie ein 2D-Spiel mit Flutter und Flame. Wenn Sie fertig sind, sollte Ihr Spiel die folgenden Anforderungen erfüllen:

  • Funktioniert auf allen sechs Plattformen, die von Flutter unterstützt werden: Android, iOS, Linux, macOS, Windows und Web
  • Halte mit dem Game-Loop von Flame mindestens 60 fps ein.
  • Verwenden Sie Flutter-Funktionen wie das Paket google_fonts und flutter_animate, um das Gefühl von Arcade-Spielen aus den 1980er-Jahren nachzubilden.

2. Flutter-Umgebung einrichten

Editor

In diesem Codelab wird davon ausgegangen, dass Visual Studio Code (VS Code) Ihre Entwicklungsumgebung ist. VS Code ist kostenlos und funktioniert auf allen wichtigen Plattformen. Wir verwenden VS Code für dieses Codelab, da in der Anleitung standardmäßig VS Code-spezifische Tastenkombinationen verwendet werden. Die Aufgaben werden einfacher: „Klicken Sie auf diese Schaltfläche“ oder „Drücken Sie diese Taste, um X auszuführen“ anstelle von „Führen Sie die entsprechende Aktion in Ihrem Editor aus, um X auszuführen“.

Sie können einen beliebigen Editor verwenden, z. B. Android Studio, andere IntelliJ-IDEs, Emacs, Vim oder Notepad++. Sie alle funktionieren mit Flutter.

VS Code mit etwas Flutter-Code

Entwicklungsziel auswählen

Mit Flutter können Apps für mehrere Plattformen erstellt werden. Ihre App kann auf einem der folgenden Betriebssysteme ausgeführt werden:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • web

Es ist üblich, ein Betriebssystem als Entwicklungsziel auszuwählen. Das ist das Betriebssystem, auf dem Ihre App während der Entwicklungsphase ausgeführt wird.

Eine Zeichnung, die einen Laptop und ein Smartphone zeigt, die über ein Kabel mit dem Laptop verbunden sind. Der Laptop ist als

Beispiel: Sie entwickeln Ihre Flutter-App auf einem Windows-Laptop. Dann wählen Sie Android als Ziel für die Entwicklung aus. Wenn Sie eine Vorschau Ihrer App ansehen möchten, schließen Sie ein Android-Gerät über ein USB-Kabel an Ihren Windows-Laptop an. Ihre App wird dann auf diesem Android-Gerät oder in einem Android-Emulator ausgeführt. Sie hätten Windows als Entwicklungsziel auswählen können. In diesem Fall würde Ihre App in der Entwicklung als Windows-App neben dem Editor ausgeführt.

Treffen Sie eine Auswahl, bevor Sie fortfahren. Sie können Ihre App später jederzeit auf anderen Betriebssystemen ausführen. Wenn Sie ein Entwicklungsziel auswählen, wird der nächste Schritt einfacher.

Flutter installieren

Die aktuellsten Anleitungen zur Installation des Flutter SDK finden Sie unter docs.flutter.dev.

Die Anleitung auf der Flutter-Website umfasst die Installation des SDK, der Tools für das Entwicklungsziel und der Editor-Plug-ins. Installieren Sie für dieses Codelab die folgende Software:

  1. Flutter SDK
  2. Visual Studio Code mit dem Flutter-Plug-in
  3. Compiler-Software für das ausgewählte Entwicklungsziel. (Für Windows benötigen Sie Visual Studio und für macOS oder iOS Xcode.)

Im nächsten Abschnitt erstellen Sie Ihr erstes Flutter-Projekt.

Wenn Sie Probleme beheben müssen, können Ihnen einige dieser Fragen und Antworten (von Stack Overflow) dabei helfen.

FAQ

3. Projekt erstellen

Erstes Flutter-Projekt erstellen

Dazu müssen Sie VS Code öffnen und die Flutter-App-Vorlage in einem von Ihnen ausgewählten Verzeichnis erstellen.

  1. Starten Sie Visual Studio Code.
  2. Öffnen Sie die Befehlspalette (F1 oder Ctrl+Shift+P oder Shift+Cmd+P) und geben Sie „flutter new“ ein. Wählen Sie den Befehl Flutter: New Project aus, wenn er angezeigt wird.

VS Code mit

  1. Wählen Sie Empty Application (Leere Anwendung) aus. Wählen Sie ein Verzeichnis aus, in dem Sie Ihr Projekt erstellen möchten. Das sollte ein beliebiges Verzeichnis sein, für das keine erhöhten Berechtigungen erforderlich sind und dessen Pfad keine Leerzeichen enthält. Beispiele sind Ihr Basisverzeichnis oder C:\src\.

VS Code mit der Option „Leere Anwendung“ als Teil des Ablaufs für neue Anwendungen

  1. Benennen Sie Ihr Projekt mit brick_breaker. Im weiteren Verlauf dieses Codelabs wird davon ausgegangen, dass Sie Ihre App brick_breaker genannt haben.

VS Code mit

Flutter erstellt nun den Projektordner und VS Code öffnet ihn. Sie überschreiben jetzt den Inhalt von zwei Dateien mit einem einfachen Gerüst der App.

Kopieren und Einfügen der ursprünglichen App

Dadurch wird der in diesem Codelab bereitgestellte Beispielcode zu Ihrer App hinzugefügt.

  1. Klicken Sie im linken Bereich von VS Code auf Explorer und öffnen Sie die Datei pubspec.yaml.

Teilweiser Screenshot von VS Code mit Pfeilen, die die Position der Datei „pubspec.yaml“ hervorheben

  1. Ersetzen Sie den Inhalt dieser Datei durch Folgendes:

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

In der Datei pubspec.yaml werden grundlegende Informationen zu Ihrer App angegeben, z. B. die aktuelle Version, die Abhängigkeiten und die Assets, die mit der App ausgeliefert werden.

  1. Öffnen Sie die Datei main.dart im Verzeichnis lib/.

Teilweiser Screenshot von VS Code mit einem Pfeil, der die Position der Datei „main.dart“ zeigt

  1. Ersetzen Sie den Inhalt dieser Datei durch Folgendes:

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Führen Sie diesen Code aus, um zu prüfen, ob alles funktioniert. Es sollte ein neues Fenster mit einem leeren schwarzen Hintergrund angezeigt werden. Das schlechteste Videospiel der Welt wird jetzt mit 60 fps gerendert!

Ein Screenshot, der ein Fenster der Anwendung „brick_breaker“ zeigt, das vollständig schwarz ist.

4. Spiel erstellen

Größe des Spiels ermitteln

Für ein zweidimensionales (2D) Spiel ist ein Spielbereich erforderlich. Sie erstellen einen Bereich mit bestimmten Abmessungen und verwenden diese Abmessungen dann, um andere Aspekte des Spiels zu dimensionieren.

Es gibt verschiedene Möglichkeiten, Koordinaten im Spielbereich anzuordnen. Gemäß einer Konvention kann die Richtung vom Mittelpunkt des Bildschirms aus gemessen werden. Der Ursprung (0,0)befindet sich in der Mitte des Bildschirms. Positive Werte verschieben Elemente nach rechts entlang der x-Achse und nach oben entlang der y-Achse. Dieser Standard gilt heutzutage für die meisten aktuellen Spiele, insbesondere für Spiele mit drei Dimensionen.

Als das ursprüngliche Breakout-Spiel entwickelt wurde, war es üblich, den Ursprung in der oberen linken Ecke festzulegen. Die positive X-Richtung blieb gleich, die Y-Richtung wurde jedoch umgekehrt. Die positive X-Richtung war rechts und die Y-Richtung war unten. Um der Zeit treu zu bleiben, wird in diesem Spiel der Ursprung in die obere linke Ecke gesetzt.

Erstellen Sie eine Datei mit dem Namen config.dart in einem neuen Verzeichnis mit dem Namen lib/src. In den folgenden Schritten werden dieser Datei weitere Konstanten hinzugefügt.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Dieses Spiel ist 820 Pixel breit und 1.600 Pixel hoch. Der Spielbereich wird so skaliert, dass er in das Fenster passt, in dem er angezeigt wird. Alle Komponenten, die dem Bildschirm hinzugefügt werden, entsprechen jedoch dieser Höhe und Breite.

PlayArea erstellen

Im Spiel Breakout prallt der Ball von den Wänden des Spielfelds ab. Um Kollisionen zu berücksichtigen, benötigen Sie zuerst eine PlayArea-Komponente.

  1. Erstellen Sie eine Datei mit dem Namen play_area.dart in einem neuen Verzeichnis mit dem Namen lib/src/components.
  2. Fügen Sie dieser Datei Folgendes hinzu.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Wo Flutter Widgets hat, hat Flame Components. Während Flutter-Apps aus dem Erstellen von Widget-Bäumen bestehen, besteht die Entwicklung von Flame-Spielen aus dem Verwalten von Komponentenbäumen.

Darin liegt ein interessanter Unterschied zwischen Flutter und Flame. Der Widget-Baum von Flutter ist eine kurzlebige Beschreibung, die zum Aktualisieren der persistenten und veränderlichen RenderObject-Ebene erstellt wird. Die Komponenten von Flame sind persistent und veränderbar. Es wird davon ausgegangen, dass der Entwickler diese Komponenten als Teil eines Simulationssystems verwendet.

Die Komponenten von Flame sind für die Darstellung von Spielmechaniken optimiert. Dieses Codelab beginnt mit der Spielschleife, die im nächsten Schritt beschrieben wird.

  1. Um Unordnung zu vermeiden, fügen Sie eine Datei mit allen Komponenten in diesem Projekt hinzu. Erstellen Sie eine components.dart-Datei in lib/src/components und fügen Sie den folgenden Inhalt hinzu.

lib/src/components/components.dart

export 'play_area.dart';

Die Anweisung export hat die umgekehrte Rolle von import. Sie deklariert, welche Funktionen diese Datei beim Importieren in eine andere Datei bereitstellt. Diese Datei enthält immer mehr Einträge, wenn Sie in den folgenden Schritten neue Komponenten hinzufügen.

Flame-Spiel erstellen

Um die roten Schlangenlinien aus dem vorherigen Schritt zu entfernen, leiten Sie eine neue Unterklasse für FlameGame von Flame ab.

  1. Erstellen Sie im Ordner lib/src eine Datei mit dem Namen brick_breaker.dart und fügen Sie den folgenden Code ein.

lib/src/brick_breaker.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

In dieser Datei werden die Aktionen des Spiels koordiniert. Während der Erstellung der Spielinstanz wird mit diesem Code das Spiel für die Verwendung von Rendering mit fester Auflösung konfiguriert. Das Spiel wird so skaliert, dass es den Bildschirm ausfüllt, auf dem es angezeigt wird. Bei Bedarf wird Letterboxing hinzugefügt.

Sie machen die Breite und Höhe des Spiels verfügbar, damit die untergeordneten Komponenten wie PlayArea die passende Größe festlegen können.

In der überschriebenen Methode onLoad führt Ihr Code zwei Aktionen aus.

  1. Konfiguriert die linke obere Ecke als Anker für den Sucher. Standardmäßig wird für viewfinder der Mittelpunkt des Bereichs als Anker für (0,0) verwendet.
  2. Fügt PlayArea zu world hinzu. Die Welt repräsentiert die Spielwelt. Alle untergeordneten Elemente werden durch die Ansichtstransformation von CameraComponent projiziert.

Spiel auf dem Bildschirm anzeigen

Wenn Sie alle Änderungen sehen möchten, die Sie in diesem Schritt vorgenommen haben, aktualisieren Sie Ihre lib/main.dart-Datei mit den folgenden Änderungen.

lib/main.dart

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

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

Nachdem Sie diese Änderungen vorgenommen haben, starten Sie das Spiel neu. Das Spiel sollte in etwa so aussehen:

Ein Screenshot, der ein Fenster der Brick Breaker-Anwendung mit einem sandfarbenen Rechteck in der Mitte des App-Fensters zeigt

Im nächsten Schritt fügen Sie der Welt einen Ball hinzu und lassen ihn sich bewegen.

5. Ball anzeigen

Ballkomponente erstellen

Um einen sich bewegenden Ball auf dem Bildschirm darzustellen, müssen Sie eine weitere Komponente erstellen und der Spielwelt hinzufügen.

  1. Bearbeiten Sie den Inhalt der Datei lib/src/config.dart so:

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

Das Designmuster, benannte Konstanten als abgeleitete Werte zu definieren, wird in diesem Codelab häufig verwendet. So können Sie die oberste Ebene gameWidth und gameHeight ändern, um zu sehen, wie sich das Erscheinungsbild des Spiels dadurch verändert.

  1. Erstellen Sie die Komponente Ball in einer Datei mit dem Namen ball.dart in lib/src/components.

lib/src/components/ball.dart

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

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

Sie haben PlayArea zuvor mit RectangleComponent definiert. Es ist also davon auszugehen, dass es noch mehr Formen gibt. CircleComponent, wie RectangleComponent, wird aus PositionedComponent abgeleitet, sodass Sie den Ball auf dem Bildschirm positionieren können. Noch wichtiger ist, dass die Position aktualisiert werden kann.

In dieser Komponente wird das Konzept der velocity oder Positionsänderung im Zeitverlauf eingeführt. Die Geschwindigkeit ist ein Vector2-Objekt, da die Geschwindigkeit sowohl die Geschwindigkeit als auch die Richtung umfasst. Um die Position zu aktualisieren, überschreiben Sie die Methode update, die von der Spiele-Engine für jeden Frame aufgerufen wird. Die dt ist die Dauer zwischen dem vorherigen und diesem Frame. So können Sie sich an Faktoren wie unterschiedliche Bildraten (60 Hz oder 120 Hz) oder lange Frames aufgrund übermäßiger Berechnungen anpassen.

Achten Sie genau auf das position += velocity * dt-Update. So implementieren Sie die Aktualisierung einer diskreten Simulation von Bewegung im Zeitverlauf.

  1. Wenn Sie die Komponente Ball in die Liste der Komponenten aufnehmen möchten, bearbeiten Sie die Datei lib/src/components/components.dart so:

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

Füge den Ball der Welt hinzu

Du hast einen Ball. Platziere es in der Welt und richte es so ein, dass es sich im Spielbereich bewegt.

Bearbeiten Sie die Datei lib/src/brick_breaker.dart so:

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;                                     // Add this import

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(                                                     // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;                                           // To here.
  }
}

Durch diese Änderung wird die Komponente Ball der world hinzugefügt. Damit die position des Balls auf die Mitte des Anzeigebereichs festgelegt wird, wird die Größe des Spiels zuerst halbiert, da Vector2 Operatorüberladungen (* und /) zum Skalieren eines Vector2 mit einem Skalarwert hat.

Das Festlegen des velocity des Balls ist komplexer. Der Ball soll sich mit angemessener Geschwindigkeit in einer zufälligen Richtung nach unten bewegen. Durch den Aufruf der Methode normalized wird ein Vector2-Objekt erstellt, das auf dieselbe Richtung wie das ursprüngliche Vector2 festgelegt ist, aber auf eine Entfernung von 1 skaliert wird. Dadurch bleibt die Geschwindigkeit des Balls unabhängig von der Richtung, in die er sich bewegt, konstant. Die Geschwindigkeit des Balls wird dann auf ein Viertel der Höhe des Spiels skaliert.

Um diese verschiedenen Werte richtig zu bestimmen, sind einige Iterationen erforderlich, die in der Branche auch als Playtesting bezeichnet werden.

In der letzten Zeile wird die Debugging-Anzeige aktiviert. Dadurch werden zusätzliche Informationen in der Anzeige eingeblendet, die bei der Fehlerbehebung helfen.

Wenn Sie das Spiel jetzt ausführen, sollte es so aussehen:

Ein Screenshot, der ein Fenster der Brick Breaker-Anwendung mit einem blauen Kreis oben auf dem sandfarbenen Rechteck zeigt. Der blaue Kreis ist mit Zahlen versehen, die seine Größe und Position auf dem Bildschirm angeben.

Sowohl die Komponente PlayArea als auch die Komponente Ball enthalten Debugging-Informationen, aber durch die Hintergrundmasken werden die Zahlen von PlayArea abgeschnitten. Der Grund dafür, dass für alles Debugging-Informationen angezeigt werden, ist, dass Sie debugMode für den gesamten Komponentenbaum aktiviert haben. Sie können das Debugging auch nur für ausgewählte Komponenten aktivieren, wenn das nützlicher ist.

Wenn Sie das Spiel einige Male neu starten, stellen Sie möglicherweise fest, dass der Ball nicht wie erwartet mit den Wänden interagiert. Dazu müssen Sie eine Kollisionserkennung hinzufügen, was Sie im nächsten Schritt tun werden.

6. Bounce Around

Kollisionserkennung hinzufügen

Mit der Kollisionserkennung wird ein Verhalten hinzugefügt, bei dem Ihr Spiel erkennt, wenn zwei Objekte miteinander in Kontakt getreten sind.

Wenn Sie dem Spiel die Kollisionserkennung hinzufügen möchten, fügen Sie das Mixin HasCollisionDetection dem Spiel BrickBreaker hinzu, wie im folgenden Code gezeigt.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;
  }
}

Damit werden die Hitboxen von Komponenten verfolgt und bei jedem Game-Tick werden Kollisions-Callbacks ausgelöst.

Um die Hitboxen des Spiels zu erstellen, ändern Sie die PlayArea-Komponente wie folgt:

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        children: [RectangleHitbox()],                          // Add this parameter
      );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Wenn Sie eine RectangleHitbox-Komponente als untergeordnetes Element von RectangleComponent hinzufügen, wird eine Hitbox für die Kollisionserkennung erstellt, die der Größe der übergeordneten Komponente entspricht. Für RectangleHitbox gibt es einen Factory-Konstruktor namens relative, wenn Sie eine Hitbox benötigen, die kleiner oder größer als die übergeordnete Komponente ist.

Ball prellen

Bisher hat das Hinzufügen der Kollisionserkennung keinen Unterschied für das Gameplay gemacht. Sie ändert sich, sobald Sie die Ball-Komponente ändern. Das Verhalten des Balls muss sich ändern, wenn er mit dem PlayArea kollidiert.

Ändern Sie die Komponente Ball so:

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],                            // Add this parameter
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

In diesem Beispiel wird eine wichtige Änderung mit dem Hinzufügen des onCollisionStart-Callbacks vorgenommen. Das im vorherigen Beispiel zu BrickBreaker hinzugefügte System zur Kollisionserkennung ruft diesen Callback auf.

Zuerst wird geprüft, ob Ball mit PlayArea kollidiert ist. Das scheint derzeit überflüssig zu sein, da es keine anderen Komponenten in der Spielwelt gibt. Das ändert sich im nächsten Schritt, wenn Sie der Welt eine Fledermaus hinzufügen. Außerdem wird eine else-Bedingung hinzugefügt, um den Fall zu berücksichtigen, dass der Ball mit anderen Objekten als dem Schläger kollidiert. Eine freundliche Erinnerung, die verbleibende Logik zu implementieren.

Wenn der Ball mit der unteren Wand kollidiert, verschwindet er einfach von der Spielfläche, obwohl er noch gut zu sehen ist. Sie bearbeiten dieses Artefakt in einem späteren Schritt mit den Effekten von Flame.

Nachdem der Ball jetzt mit den Wänden des Spiels kollidiert, wäre es sicher nützlich, dem Spieler einen Schläger zu geben, mit dem er den Ball schlagen kann.

7. Den Ball treffen

BAT-Datei erstellen

So fügen Sie einen Schläger hinzu, um den Ball im Spiel zu halten:

  1. Fügen Sie einige Konstanten in die Datei lib/src/config.dart ein.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

Die Konstanten batHeight und batWidth sind selbsterklärend. Die Konstante batStep hingegen bedarf einer Erklärung. Um in diesem Spiel mit dem Ball zu interagieren, kann der Spieler den Schläger je nach Plattform mit der Maus oder dem Finger ziehen oder die Tastatur verwenden. Mit der Konstanten batStep wird konfiguriert, wie weit das Paddel bei jedem Drücken des Links- oder Rechtspfeils bewegt wird.

  1. Definieren Sie die Bat-Komponentenklasse so:

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

Diese Komponente bietet einige neue Funktionen.

Zuerst: Die Bat-Komponente ist ein PositionComponent, kein RectangleComponent und kein CircleComponent. Das bedeutet, dass dieser Code die Bat auf dem Bildschirm rendern muss. Dazu wird der render-Callback überschrieben.

Wenn Sie sich den Aufruf canvas.drawRRect (abgerundetes Rechteck zeichnen) genauer ansehen, fragen Sie sich vielleicht, wo das Rechteck ist. Die Offset.zero & size.toSize() nutzt eine operator &-Überladung in der dart:ui-Klasse Offset, die Rects erstellt. Diese Abkürzung mag Sie anfangs verwirren, aber sie wird häufig in Flutter- und Flame-Code auf niedriger Ebene verwendet.

Zweitens kann diese Bat-Komponente je nach Plattform mit dem Finger oder der Maus gezogen werden. Um diese Funktion zu implementieren, fügen Sie den DragCallbacks-Mixin hinzu und überschreiben das onDragUpdate-Ereignis.

Schließlich muss die Komponente Bat auf die Tastatursteuerung reagieren. Mit der Funktion moveBy kann anderer Code dieser Fledermaus mitteilen, dass sie sich um eine bestimmte Anzahl virtueller Pixel nach links oder rechts bewegen soll. Diese Funktion führt eine neue Funktion der Flame-Game-Engine ein: Effect. Wenn Sie das Objekt MoveToEffect als untergeordnetes Element dieser Komponente hinzufügen, wird der Schläger in einer neuen Position animiert. In Flame sind verschiedene Effects verfügbar, mit denen sich unterschiedliche Effekte erzielen lassen.

Die Konstruktorargumente des Effekts enthalten einen Verweis auf den Getter game. Deshalb fügen Sie dieser Klasse den HasGameReference-Mixin hinzu. Dieser Mixin fügt dieser Komponente einen typsicheren game-Accessor hinzu, um auf die BrickBreaker-Instanz oben im Komponentenbaum zuzugreifen.

  1. Damit die Bat für BrickBreaker verfügbar ist, aktualisieren Sie die Datei lib/src/components/components.dart so:

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

Füge die Fledermaus der Welt hinzu

Wenn Sie die Bat-Komponente der Spielwelt hinzufügen möchten, aktualisieren Sie BrickBreaker so:

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(                                                  // Add from here...
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );                                                          // To here.

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here.
}

Das Hinzufügen des Mixins KeyboardEvents und die überschriebene Methode onKeyEvent verarbeiten die Tastatureingabe. Rufen Sie den Code auf, den Sie zuvor hinzugefügt haben, um die Fledermaus um den entsprechenden Schrittbetrag zu bewegen.

Im verbleibenden Teil des hinzugefügten Codes wird die Fledermaus an der richtigen Position und mit den richtigen Proportionen in die Spielwelt eingefügt. Da alle diese Einstellungen in dieser Datei verfügbar sind, können Sie die relative Größe von Schläger und Ball ganz einfach anpassen, um das richtige Spielgefühl zu erzielen.

Wenn Sie das Spiel jetzt spielen, können Sie den Schläger bewegen, um den Ball abzufangen. Sie erhalten jedoch keine sichtbare Reaktion, abgesehen von der Debug-Protokollierung, die Sie im Kollisionserkennungscode von Ball hinterlassen haben.

Das beheben wir jetzt. Bearbeiten Sie die Komponente Ball so:

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));                         // Modify from here...
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Mit diesen Codeänderungen werden zwei separate Probleme behoben.

Erstens wird das Problem behoben, dass der Ball verschwindet, sobald er den unteren Bildschirmrand berührt. Um dieses Problem zu beheben, ersetzen Sie den removeFromParent-Aufruf durch RemoveEffect. Der RemoveEffect entfernt den Ball aus der Spielwelt, nachdem er den sichtbaren Spielbereich verlassen hat.

Zweitens wird durch diese Änderungen die Kollision zwischen Schläger und Ball behoben. Dieser Code zur Verarbeitung kommt dem Spieler sehr entgegen. Solange der Spieler den Ball mit dem Schläger berührt, kehrt der Ball an den oberen Bildschirmrand zurück. Wenn dir das zu nachsichtig ist und du etwas Realistischeres möchtest, kannst du die Steuerung anpassen.

Es ist wichtig, auf die Komplexität der velocity-Aktualisierung hinzuweisen. Dabei wird nicht nur die y-Komponente der Geschwindigkeit umgekehrt, wie es bei den Wandkollisionen der Fall war. Außerdem wird die x-Komponente in einer Weise aktualisiert, die von der relativen Position von Schläger und Ball zum Zeitpunkt des Kontakts abhängt. So hat der Spieler mehr Kontrolle darüber, was mit dem Ball passiert. Wie genau, wird ihm aber nur durch das Spiel selbst mitgeteilt.

Jetzt, da Sie einen Schläger haben, mit dem Sie den Ball schlagen können, wäre es schön, wenn Sie auch einige Ziegelsteine hätten, die Sie mit dem Ball zerbrechen könnten.

8. Die Mauer durchbrechen

Bricks erstellen

So fügst du dem Spiel Steine hinzu:

  1. Fügen Sie einige Konstanten in die Datei lib/src/config.dart ein.

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. Fügen Sie die Komponente Brick so ein:

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Der Großteil dieses Codes sollte Ihnen inzwischen bekannt sein. In diesem Code wird ein RectangleComponent verwendet, mit sowohl Kollisionserkennung als auch einem typsicheren Verweis auf das BrickBreaker-Spiel oben im Komponentenbaum.

Das wichtigste neue Konzept, das in diesem Code eingeführt wird, ist, wie der Spieler die Gewinnbedingung erreicht. Bei der Prüfung der Gewinnbedingung werden alle Bricks in der Welt abgefragt und es wird bestätigt, dass nur noch einer vorhanden ist. Das kann etwas verwirrend sein, da in der vorherigen Zeile dieser Brick aus dem übergeordneten Element entfernt wird.

Wichtig ist, dass das Entfernen von Komponenten ein in die Warteschlange eingestellter Befehl ist. Der Stein wird nach der Ausführung dieses Codes, aber vor dem nächsten Tick der Spielwelt entfernt.

Damit die Komponente Brick für BrickBreaker zugänglich ist, bearbeiten Sie lib/src/components/components.dart so:

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

Der Welt Steine hinzufügen

Aktualisieren Sie die Ball-Komponente so:

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

Das ist der einzige neue Aspekt: ein Schwierigkeitsmodifikator, der die Ballgeschwindigkeit nach jeder Kollision mit einem Ziegelstein erhöht. Dieser abstimmbare Parameter muss durch Playtests ermittelt werden, um die für Ihr Spiel geeignete Schwierigkeitskurve zu finden.

Bearbeiten Sie das Spiel BrickBreaker so:

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

Wenn Sie das Spiel ausführen, werden alle wichtigen Spielmechaniken angezeigt. Sie könnten das Debugging deaktivieren und die Aufgabe als erledigt markieren, aber irgendetwas fehlt.

Ein Screenshot von „brick_breaker“ mit Ball, Schläger und den meisten Steinen auf dem Spielfeld. Jede Komponente hat Debugging-Labels.

Wie wäre es mit einem Begrüßungsbildschirm, einem „Game Over“-Bildschirm und vielleicht einem Punktestand? Flutter kann diese Funktionen dem Spiel hinzufügen. Das ist der nächste Schritt.

9. Spiel gewinnen

Wiedergabestatus hinzufügen

In diesem Schritt betten Sie das Flame-Spiel in einen Flutter-Wrapper ein und fügen dann Flutter-Overlays für die Begrüßungs-, Game-over- und Gewonnen-Bildschirme hinzu.

Zuerst müssen Sie die Spiel- und Komponentendateien so ändern, dass ein Spielstatus implementiert wird, der angibt, ob ein Overlay angezeigt werden soll und wenn ja, welches.

  1. Ändern Sie das Spiel BrickBreaker so:

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

Dieser Code ändert einen Großteil des Spiels BrickBreaker. Das Hinzufügen der Aufzählung playState ist mit viel Arbeit verbunden. Hier wird erfasst, in welcher Phase sich der Spieler befindet: beim Betreten des Spiels, beim Spielen oder beim Verlieren oder Gewinnen des Spiels. Oben in der Datei definieren Sie die Enumeration und instanziieren sie dann als verborgenen Status mit passenden Gettern und Settern. Mit diesen Gettern und Settern können Overlays geändert werden, wenn durch die verschiedenen Teile des Spiels Übergänge zum Wiedergabestatus ausgelöst werden.

Als Nächstes teilen Sie den Code in onLoad in „onLoad“ und eine neue startGame-Methode auf. Bisher konnten Sie nur ein neues Spiel starten, indem Sie das Spiel neu gestartet haben. Dank dieser Neuerungen kann der Spieler jetzt ein neues Spiel starten, ohne so drastische Maßnahmen ergreifen zu müssen.

Damit der Spieler ein neues Spiel starten kann, haben Sie zwei neue Handler für das Spiel konfiguriert. Sie haben einen Tap-Handler hinzugefügt und den Keyboard-Handler erweitert, damit der Nutzer ein neues Spiel in verschiedenen Modalitäten starten kann. Wenn der Wiedergabestatus modelliert wird, ist es sinnvoll, die Komponenten so zu aktualisieren, dass Wiedergabestatusübergänge ausgelöst werden, wenn der Spieler gewinnt oder verliert.

  1. Ändern Sie die Komponente Ball so:

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            },
          ),
        );                                                      // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

Durch diese kleine Änderung wird dem RemoveEffect ein onComplete-Callback hinzugefügt, der den Wiedergabestatus gameOver auslöst. Das sollte ungefähr passen, wenn der Spieler den Ball vom unteren Bildschirmrand entkommen lässt.

  1. Bearbeiten Sie die Komponente Brick so:

lib/src/components/brick.dart

impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Wenn der Spieler alle Ziegel zerstören kann, wird der Bildschirm „Spiel gewonnen“ angezeigt. Gut gemacht!

Flutter-Wrapper hinzufügen

Fügen Sie die Flutter-Shell hinzu, um das Spiel einzubetten und Overlays für den Spielstatus hinzuzufügen.

  1. Erstellen Sie unter lib/src ein Verzeichnis widgets.
  2. Fügen Sie eine game_app.dart-Datei hinzu und fügen Sie den folgenden Inhalt in diese Datei ein.

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                          child: Text(
                            'TAP TO PLAY',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.gameOver.name: (context, game) => Center(
                          child: Text(
                            'G A M E   O V E R',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.won.name: (context, game) => Center(
                          child: Text(
                            'Y O U   W O N ! ! !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Die meisten Inhalte in dieser Datei folgen einem standardmäßigen Flutter-Widget-Baum. Die für Flame spezifischen Teile umfassen die Verwendung von GameWidget.controlled zum Erstellen und Verwalten der BrickBreaker-Spielinstanz und das neue overlayBuilderMap-Argument für GameWidget.

Die Schlüssel dieses overlayBuilderMap müssen mit den Overlays übereinstimmen, die der playState-Setter in BrickBreaker hinzugefügt oder entfernt hat. Wenn Sie versuchen, ein Overlay festzulegen, das nicht auf dieser Karte vorhanden ist, führt das zu unzufriedenen Gesichtern.

  1. Um diese neue Funktion auf dem Bildschirm zu sehen, ersetzen Sie die Datei lib/main.dart durch den folgenden Inhalt.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

Wenn Sie diesen Code unter iOS, Linux, Windows oder im Web ausführen, wird die beabsichtigte Ausgabe im Spiel angezeigt. Wenn Sie macOS oder Android als Zielplattform verwenden, müssen Sie noch eine letzte Anpassung vornehmen, damit google_fonts angezeigt wird.

Schriftartenzugriff aktivieren

Internetberechtigung für Android hinzufügen

Bei Android müssen Sie die Internetberechtigung hinzufügen. Bearbeiten Sie AndroidManifest.xml so:

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

Berechtigungsdateien für macOS bearbeiten

Unter macOS müssen Sie zwei Dateien bearbeiten.

  1. Bearbeiten Sie die Datei DebugProfile.entitlements so, dass sie dem folgenden Code entspricht.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Bearbeiten Sie die Datei Release.entitlements so, dass sie dem folgenden Code entspricht.

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

Wenn Sie das Spiel so ausführen, sollte auf allen Plattformen ein Begrüßungsbildschirm und ein Bildschirm mit der Meldung „Game Over“ oder „Gewonnen“ angezeigt werden. Diese Bildschirme sind vielleicht etwas zu einfach und es wäre schön, wenn es eine Punktzahl gäbe. Raten Sie mal, was Sie im nächsten Schritt tun müssen.

10. Ergebnisse im Blick behalten

Punktzahl zum Spiel hinzufügen

In diesem Schritt machen Sie den Spielstand für den umgebenden Flutter-Kontext verfügbar. In diesem Schritt stellen Sie den Status des Flame-Spiels für die umgebende Flutter-Statusverwaltung bereit. Dadurch kann der Spielcode die Punktzahl jedes Mal aktualisieren, wenn der Spieler einen Ziegelstein zerstört.

  1. Ändern Sie das Spiel BrickBreaker so:

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

Wenn Sie score dem Spiel hinzufügen, verknüpfen Sie den Spielstatus mit der Flutter-Statusverwaltung.

  1. Ändern Sie die Brick-Klasse, um dem Score einen Punkt hinzuzufügen, wenn der Spieler Steine zerbricht.

lib/src/components/brick.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Ein ansprechendes Spiel entwickeln

Nachdem Sie jetzt Punkte in Flutter zählen können, ist es an der Zeit, die Widgets zusammenzustellen, damit das Ganze gut aussieht.

  1. Erstellen Sie score_card.dart in lib/src/widgets und fügen Sie Folgendes hinzu.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({super.key, required this.score});

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Erstellen Sie overlay_screen.dart in lib/src/widgets und fügen Sie den folgenden Code hinzu.

Dadurch werden die Overlays durch die Verwendung des flutter_animate-Pakets noch ansprechender gestaltet, da den Overlay-Bildschirmen Bewegung und Stil hinzugefügt werden.

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({super.key, required this.title, required this.subtitle});

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

Wenn Sie sich genauer ansehen möchten, wie leistungsstark flutter_animate ist, können Sie das Codelab zum Erstellen von Benutzeroberflächen der nächsten Generation in Flutter durcharbeiten.

Dieser Code hat sich in der Komponente GameApp stark verändert. Damit ScoreCard auf score zugreifen kann, müssen Sie es zuerst von StatelessWidget in StatefulWidget umwandeln. Für die Kurzübersicht muss ein Column hinzugefügt werden, damit der Score über dem Spiel angezeigt wird.

Zweitens haben Sie das neue OverlayScreen-Widget hinzugefügt, um die Begrüßung, das Spielende und den Gewinn zu verbessern.

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Wenn alles eingerichtet ist, sollten Sie das Spiel jetzt auf allen sechs Flutter-Zielplattformen ausführen können. Das Spiel sollte in etwa so aussehen.

Ein Screenshot von „Brick Breaker“, auf dem der Bildschirm vor dem Spiel zu sehen ist, auf dem der Nutzer aufgefordert wird, auf den Bildschirm zu tippen, um das Spiel zu starten

Screenshot von „brick_breaker“, auf dem der Game-over-Bildschirm über einem Schläger und einigen der Steine zu sehen ist

11. Glückwunsch

Herzlichen Glückwunsch! Sie haben erfolgreich ein Spiel mit Flutter und Flame entwickelt.

Sie haben ein Spiel mit der 2D-Spiel-Engine Flame entwickelt 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

Hier sind einige Codelabs:

Weitere Informationen