Einführung in Flame mit Flutter

1. Einführung

Flame ist eine Flutter-basierte 2D-Spiel-Engine. In diesem Codelab entwickeln Sie ein Spiel, das von einem der Klassiker der Videospiele der 70er, Steve Wozniaks Breakout, inspiriert ist. Du verwendest Flame's Components, um den Schläger, den Ball und die Bausteine zu zeichnen. Du nutzt Flame's Effects, um die Bewegung des Schlägers zu animieren und zu erfahren, wie du Flame in das State Management System von Flutter einbindest.

Wenn das Spiel fertig ist, sollte es wie dieses animierte GIF aussehen, nur ein bisschen langsamer.

Eine Bildschirmaufzeichnung eines Spiels, das gerade gespielt wird. Das Spiel wurde deutlich beschleunigt.

Lerninhalte

  • Wie die Grundlagen von Flame funktionieren, beginnend mit GameWidget.
  • Spielschleife verwenden
  • So funktionieren Component von Flame. Sie ähneln den Widget von Flutter.
  • Umgang mit Konflikten
  • So verwenden Sie Effect-Elemente zur Animation von Component-Elementen.
  • Hier erfährst du, wie du Flutter-Widgets in einem Flame-Spiel überlagern kannst.
  • Flame in das State Management von Flutter einbinden

Aufgaben

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

  • Funktion auf allen sechs Plattformen, die von Flutter unterstützt werden: Android, iOS, Linux, macOS, Windows und das Web
  • Halte in der Spielschleife von Flame mindestens 60 fps aufrecht.
  • Mit Flutter-Funktionen wie dem google_fonts-Paket und flutter_animate kannst du das Arcade-Game der 80er-Jahre nachahmen.

2. Flutter-Umgebung einrichten

Editor

Zur Vereinfachung dieses Codelabs wird davon ausgegangen, dass Visual Studio Code (VS Code) Ihre Entwicklungsumgebung ist. VS Code ist kostenlos und funktioniert auf allen gängigen Plattformen. Für dieses Codelab verwenden wir VS Code, da in der Anleitung standardmäßig VS Code-spezifische Tastenkombinationen verwendet werden. Die Aufgaben werden einfacher: „Klicke auf diese Schaltfläche“ oder „Drücke diese Taste, um X zu tun“ anstatt „im Editor die entsprechende Aktion auszuführen, um X zu tun“.

Sie können einen beliebigen Editor verwenden: Android Studio, andere IntelliJ IDEs, Emacs, Vim oder Notepad++. Sie alle sind mit Flutter kompatibel.

Screenshot von VS Code mit Flutter-Code

Entwicklungsziel auswählen

Flutter entwickelt Apps für mehrere Plattformen. Ihre App kann unter einem der folgenden Betriebssysteme ausgeführt werden:

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

In der Regel wird ein einziges Betriebssystem als Entwicklungsziel ausgewählt. Dies ist das Betriebssystem, auf dem Ihre App während der Entwicklung ausgeführt wird.

Eine Zeichnung, die einen Laptop und ein Smartphone zeigt, das über ein Kabel mit dem Laptop verbunden ist. Der Laptop trägt die Bezeichnung

Beispiel: Sie verwenden einen Windows-Laptop, um Ihre Flutter-App zu entwickeln. Anschließend wählen Sie Android als Entwicklungsziel aus. Um eine Vorschau Ihrer App zu sehen, schließen Sie ein Android-Gerät über ein USB-Kabel an Ihren Windows-Laptop an. Ihre App wird während der Entwicklung auf diesem angeschlossenen Android-Gerät oder in einem Android-Emulator ausgeführt. Sie hätten Windows als Entwicklungsziel wählen können, wo Ihre App während der Entwicklung als Windows-App zusammen mit Ihrem Editor ausgeführt wird.

Sie könnten versucht sein, das Web als Entwicklungsziel zu wählen. Dies hat einen Nachteil während der Entwicklung: Sie verlieren die Funktion Stateful Hot Refresh von Flutter. Flutter kann derzeit keine Hot-Reloads für Webanwendungen durchführen.

Triff eine Auswahl, bevor du fortfährst. Sie können Ihre App später jederzeit unter anderen Betriebssystemen ausführen. Die Auswahl eines Entwicklungsziels erleichtert den nächsten Schritt.

Flutter installieren

Eine aktuelle Anleitung zur Installation des Flutter SDK finden Sie unter docs.flutter.dev.

Die Anleitung auf der Flutter-Website umfasst die Installation des SDK und der Entwicklungstools sowie die 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 dein gewähltes Entwicklungsziel. Sie benötigen Visual Studio für Windows oder Xcode für macOS oder iOS.

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

Wenn Sie Probleme beheben müssen, könnten einige dieser Fragen und Antworten (von StackOverflow) hilfreich für die Fehlerbehebung sein.

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, Ctrl+Shift+P oder Shift+Cmd+P) und geben Sie „flutter new“ ein. Wählen Sie dann den Befehl Flutter: New Project (Flutter: Neues Projekt) aus.

Screenshot von 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. Dies sollte ein beliebiges Verzeichnis sein, für das keine erhöhten Berechtigungen erforderlich sind oder das ein Leerzeichen im Pfad hat. Beispiele hierfür sind Ihr Basisverzeichnis oder C:\src\.

Screenshot von VS Code mit leerer Anwendung, der als Teil des neuen Anwendungsablaufs ausgewählt ist

  1. Geben Sie dem Projekt den Namen brick_breaker. Für den Rest dieses Codelabs wird vorausgesetzt, dass Sie der App den Namen brick_breaker gegeben haben.

Ein Screenshot von VS Code mit

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

Kopieren und Erste App einfügen

Dadurch wird der Beispielcode aus diesem Codelab Ihrer App hinzugefügt.

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

Screenshot eines Teils von VS Code mit Pfeilen, die den Speicherort der Datei „pubspec.yaml“ markieren

  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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

In der Datei pubspec.yaml sind grundlegende Informationen zu deiner App angegeben, z. B. die aktuelle Version, die Abhängigkeiten sowie die Assets, mit denen sie ausgeliefert wird.

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

Ein Teil-Screenshot von VS Code mit einem Pfeil, der den Speicherort 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. Ein neues Fenster mit nur einem leeren schwarzen Hintergrund sollte angezeigt werden. Das schlimmste Videospiel der Welt wird jetzt mit 60 fps gerendert.

Screenshot mit einem völlig schwarzen Anwendungsfenster Brick_breaker

4. Spiel erstellen

Das passende Spiel für dich

Für ein in zwei Dimensionen (2D) gespieltes Spiel ist ein Spielbereich erforderlich. Sie konstruieren einen Bereich mit bestimmten Abmessungen und verwenden diese Dimensionen dann, um die Größe anderer Aspekte des Spiels anzupassen.

Es gibt verschiedene Möglichkeiten, die Koordinaten im Spielbereich festzulegen. Mit einer Konvention können Sie die Richtung von der Bildschirmmitte aus messen, wobei sich der Ursprung (0,0) in der Mitte des Bildschirms befindet. Die positiven Werte verschieben die Elemente entlang der x-Achse nach rechts und entlang der y-Achse nach oben. Dieser Standard gilt heutzutage für die meisten aktuellen Spiele, insbesondere für Spiele mit drei Dimensionen.

Bei der Entwicklung des ersten Breakout-Spiels sollte der Ursprung in der oberen linken Ecke festgelegt werden. Die positive x-Richtung ist gleich geblieben, aber y wurde umgedreht. Die positive x-Richtung war nach rechts und y nach unten. Um der Zeit treu zu bleiben, setzt dieses Spiel den Ursprung in die obere linke Ecke.

Erstellen Sie eine Datei mit dem Namen config.dart in einem neuen Verzeichnis namens lib/src. Diese Datei erhält in den folgenden Schritten weitere Konstanten.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Dieses Spiel wird 820 Pixel breit und 1.600 Pixel hoch sein. Der Spielbereich wird an das Fenster angepasst, in dem er angezeigt wird, aber alle Komponenten, die dem Bildschirm hinzugefügt werden, entsprechen dieser Höhe und Breite.

PlayArea erstellen

Beim Spiel Breakout prallt der Ball von den Wänden des Spielbereichs ab. Du benötigst zuerst eine PlayArea-Komponente, um Konflikte zu vermeiden.

  1. Erstellen Sie eine Datei mit dem Namen play_area.dart in einem neuen Verzeichnis namens 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);
  }
}

Während Flutter Widgets hat, hat Flame Components. Während Flutter-Apps Bäume aus Widgets erstellen, werden bei Flame-Spielen Bäume von Komponenten beibehalten.

Es gibt einen interessanten Unterschied zwischen Flutter und Flame. Die Widget-Struktur von Flutter ist eine sitzungsspezifische Beschreibung, die zum Aktualisieren der persistenten und änderbaren RenderObject-Ebene dient. Die Komponenten von Flame sind persistent und veränderbar, wobei davon ausgegangen wird, dass der Entwickler diese Komponenten als Teil eines Simulationssystems verwendet.

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

  1. Fügen Sie eine Datei mit allen Komponenten in diesem Projekt hinzu, um die Übersichtlichkeit zu erhöhen. 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 spielt die umgekehrte Rolle von import. Sie legt fest, welche Funktionen diese Datei verfügbar macht, wenn sie in eine andere Datei importiert wird. Wenn Sie in den folgenden Schritten neue Komponenten hinzufügen, werden in dieser Datei mehr Einträge hinzugefügt.

Erstelle ein Flame-Spiel

Leiten Sie zum Löschen der roten Wellen aus dem vorherigen Schritt eine neue abgeleitete Klasse für die FlameGame von Flame ab.

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

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

Diese Datei koordiniert die Aktionen des Spiels. Beim Erstellen der Spielinstanz wird das Spiel durch diesen Code so konfiguriert, dass das Rendering mit fester Auflösung verwendet wird. Die Größe des Spiels wird so angepasst, dass es den gesamten Bildschirm ausfüllt, der es enthält, und es werden bei Bedarf Letterboxing-Balken hinzugefügt.

Sie geben die Breite und Höhe des Spiels an, damit die untergeordneten Komponenten wie PlayArea sich selbst auf die richtige Größe einstellen können.

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

  1. Konfiguriert oben links als Anker für den Sucher. Standardmäßig verwendet der Sucher die Mitte des Bereichs als Anker für (0,0).
  2. Fügt PlayArea zum world hinzu. Die Welt steht für die Spielwelt. Sie projiziert alle untergeordneten Elemente über die CameraComponents-Ansichtstransformation.

Spiel auf dem Display

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

Starte das Spiel neu, nachdem du diese Änderungen vorgenommen hast. Das Spiel sollte der folgenden Abbildung ähneln.

Screenshot, der ein Brick_breaker-Anwendungsfenster mit einem sandfarbenen Rechteck in der Mitte des App-Fensters zeigt

Im nächsten Schritt fügst du der Welt einen Ball hinzu und bringst ihn in Bewegung!

5. Den Ball präsentieren

Ball-Komponente erstellen

Um einen sich bewegenden Ball auf dem Bildschirm zu platzieren, müssen Sie eine weitere Komponente erstellen und zur 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 zum Definieren benannter Konstanten als abgeleitete Werte wird in diesem Codelab viele Male zurückgegeben. So kannst du die gameWidth und die gameHeight der obersten Ebene ändern und herausfinden, wie sich das Spiel dadurch verändert.

  1. Erstellen Sie die Komponente Ball in einer Datei namens 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;
  }
}

Zuvor haben Sie das PlayArea mit RectangleComponent definiert. Es liegt also nahe, dass es mehr Formen gibt. CircleComponent leitet sich wie RectangleComponent von PositionedComponent ab, sodass du den Ball auf dem Bildschirm positionieren kannst. Und was noch wichtiger ist: Die Position kann aktualisiert werden.

Mit dieser Komponente wird das Konzept der velocity oder der Positionsänderung im Laufe der Zeit eingeführt. Geschwindigkeit ist ein Vector2-Objekt, da Geschwindigkeit sowohl Geschwindigkeit als auch Richtung ist. Überschreiben Sie zum Aktualisieren der Position die Methode update, die die Spiel-Engine für jeden Frame aufruft. dt ist die Dauer zwischen dem vorherigen und diesem Frame. So können Sie sich an Faktoren wie unterschiedliche Framerates (60 Hz oder 120 Hz) oder lange Frames aufgrund übermäßiger Berechnungen anpassen.

Sieh dir das position += velocity * dt-Update genau an. Auf diese Weise implementieren Sie die Aktualisierung einer diskreten Simulation von Bewegungen im Zeitverlauf.

  1. Um die Komponente Ball in die Liste der Komponenten aufzunehmen, bearbeiten Sie die Datei lib/src/components/components.dart wie folgt.

lib/src/components/components.dart

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

Der Welt den Ball hautnah erleben

Du hast einen Ball. Platzieren Sie es nun auf der Welt und richten Sie es so ein, dass es sich im Spielbereich bewegen kann.

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“ dem Element „world“ hinzugefügt. Um die position des Balls in die Mitte des Anzeigebereichs zu setzen, halbiert der Code zuerst die Größe des Spiels, da Vector2 über viele Operatoren (* und /) verfügt, um eine Vector2 um einen skalaren Wert zu skalieren.

Das Festlegen von velocity für den Ball erfordert mehr Komplexität. Die Absicht besteht darin, den Ball in einer zufälligen Richtung mit einer angemessenen Geschwindigkeit auf dem Bildschirm nach unten zu bewegen. Durch den Aufruf der Methode normalized wird ein Vector2-Objekt erstellt, das in die gleiche Richtung wie das ursprüngliche Vector2-Objekt festgelegt, aber auf eine Entfernung von 1 verkleinert wird. Auf diese Weise bleibt die Geschwindigkeit des Balls gleich, unabhängig davon, in welche Richtung der Ball geht. Die Geschwindigkeit des Balls wird dann auf ein Viertel der Höhe des Spiels erhöht.

Diese verschiedenen Werte richtig zu finden, erfordert eine gewisse Iteration, in der Branche auch Spieltests genannt.

Mit der letzten Zeile wird die Debugging-Anzeige aktiviert, die zusätzliche Informationen für die Fehlerbehebung enthält.

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

Screenshot mit einem Brick_breaker-Anwendungsfenster mit einem blauen Kreis auf dem sandfarbenen Rechteck Der blaue Kreis ist mit Zahlen versehen, die seine Größe und Position auf dem Bildschirm angeben.

Sowohl die PlayArea- als auch die Ball-Komponente enthalten Debugging-Informationen, aber durch die Hintergrundmatten werden die Zahlen der PlayArea abgeschnitten. Es werden immer Informationen zur Fehlerbehebung angezeigt, weil Sie debugMode für die gesamte Komponentenstruktur aktiviert haben. Sie können das Debugging auch nur für ausgewählte Komponenten aktivieren, wenn dies nützlicher ist.

Wenn Sie das Spiel einige Male neu starten, werden Sie feststellen, dass der Ball nicht wie erwartet mit den Wänden interagiert. Um diesen Effekt zu erreichen, müssen Sie im nächsten Schritt die Kollisionserkennung hinzufügen.

6. Hin- und herspringen

Kollisionserkennung hinzufügen

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

Um dem Spiel die Kollisionserkennung hinzuzufügen, fügen Sie das HasCollisionDetection-Mixin zum 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 Hitboxes von Komponenten verfolgt und Kollisions-Callbacks bei jedem Spiel-Tick ausgelöst.

Ändern Sie die PlayArea-Komponente wie unten gezeigt, um die Hitbox des Spiels mit Daten zu füllen.

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 ein Trefferfeld für die Kollisionserkennung erstellt, das der Größe der übergeordneten Komponente entspricht. Es gibt einen Factory-Konstruktor für RectangleHitbox mit dem Namen relative, wenn Sie eine Hitbox benötigen, die kleiner oder größer als die übergeordnete Komponente ist.

Den Ball hüpfen

Bisher machte die Kollisionserkennung noch keinen Unterschied zum Gameplay. Sie ändert sich, wenn Sie die Komponente „Ball“ ändern. Das Verhalten des Balls muss sich ändern, wenn er mit PlayArea kollidiert.

Ändere 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 durch Hinzufügen des onCollisionStart-Callbacks eine größere Änderung vorgenommen. Das im vorherigen Beispiel zu BrickBreaker hinzugefügte System zur Kollisionserkennung ruft diesen Callback auf.

Zuerst testet der Code, ob Ball mit PlayArea kollidiert. Dies erscheint vorerst überflüssig, da es keine anderen Komponenten in der Spielewelt gibt. Das ändert sich im nächsten Schritt, wenn du der Welt einen Schläger hinzufügst. Außerdem wird eine else-Bedingung hinzugefügt, die behandelt wird, wenn der Ball mit Dingen kollidiert, die nicht der Schläger sind. Eine sanfte Erinnerung, die verbleibende Logik zu implementieren, wenn Sie möchten.

Wenn der Ball mit der unteren Wand kollidiert, verschwindet er einfach von der Spielfläche, obwohl er noch immer im Sichtfeld ist. Dieses Artefakt wird in einem späteren Schritt mithilfe der Macht von Flame's Effects verarbeitet.

Jetzt, da der Ball mit den Wänden des Spiels zusammenstoßt, wäre es sicher hilfreich, dem Spieler einen Schläger zu geben, mit dem er den Ball schlagen kann...

7. Schlag auf den Ball

Erstelle den Fledermaus

Um einen Schläger hinzuzufügen, damit der Ball weiter im Spiel bleibt,

  1. Fügen Sie einige Konstanten so 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. Für die batStep-Konstante ist hingegen eine Erklärung erforderlich. 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. Die batStep-Konstante legt fest, wie weit der Schläger bei jedem Drücken der Pfeiltasten nach links oder rechts weitergehen soll.

  1. Definieren Sie die Komponentenklasse Bat 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),
    ));
  }
}

Mit dieser Komponente werden einige neue Funktionen eingeführt.

Erstens ist die Bat-Komponente ein PositionComponent, kein RectangleComponent oder CircleComponent. Das bedeutet, dass dieser Code 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 ist das Rechteck?“ Offset.zero & size.toSize() nutzt eine operator &-Überlastung in der dart:ui-Klasse Offset, die Rects erstellt. Diese Kurzschreibweise könnte Sie anfangs verwirren, wird Sie jedoch häufig in Lower-Level-Flutter- und Flame-Codes sehen.

Zweitens kann die Bat-Komponente je nach Plattform entweder mit dem Finger oder der Maus verschoben werden. Zur Implementierung dieser Funktion fügen Sie das DragCallbacks-Mixin hinzu und überschreiben das Ereignis onDragUpdate.

Schließlich muss die Komponente „Bat“ auf die Tastatursteuerung reagieren. Die moveBy-Funktion ermöglicht anderem Code, diesen Schläger anzuweisen, sich um eine bestimmte Anzahl virtueller Pixel nach links oder rechts zu bewegen. Mit dieser Funktion wird eine neue Funktion der Flame-Spiel-Engine eingeführt: Effects. Durch Hinzufügen des MoveToEffect-Objekts als untergeordnetes Element dieser Komponente sieht der Spieler den Schläger an einer neuen Position animiert. Es gibt eine Sammlung von Effects in Flame, mit denen du eine Vielzahl von Effekten ausführen kannst.

Die Konstruktorargumente des Effect enthalten einen Verweis auf den Getter game. Aus diesem Grund nehmen Sie das HasGameReference-Mixin in diesen Kurs auf. Dieses Mixin fügt dieser Komponente eine typsichere game-Zugriffsfunktion hinzu, um auf die BrickBreaker-Instanz oben in der Komponentenstruktur zuzugreifen.

  1. Um die Bat für BrickBreaker verfügbar zu machen, 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';

Die Welt mit dem Schläger

Wenn du der Spielwelt die Komponente „Bat“ hinzufügen möchtest, aktualisiere 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(Bat(                                              // Add from here...
        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 KeyboardEvents-Mixins und die überschriebene onKeyEvent-Methode verarbeiten die Tastatureingabe. Erinnere dich an den Code, den du zuvor hinzugefügt hast, um den Schläger um den entsprechenden Schrittbetrag zu bewegen.

Der verbleibende Code-Chunk fügt den Schläger an der entsprechenden Position und im richtigen Verhältnis zur Spielwelt hinzu. Alle diese Einstellungen in dieser Datei erleichtern dir die Anpassung der relativen Größe des Schlägers und des Balls, um das richtige Spielgefühl zu bekommen.

Wenn du das Spiel an dieser Stelle spielst, siehst du, dass du den Schläger bewegen kannst, um den Ball zu fangen. Abgesehen vom Debugging-Logging, das du im Kollisionserkennungscode von Ball belassen hast, erhältst du jedoch keine sichtbare Antwort.

Zeit, das jetzt zu beheben. So bearbeiten Sie die Komponente „Ball“:

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(                                       // Modify from here...
          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 {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Durch diese Codeänderungen werden zwei unterschiedliche Probleme behoben.

Zuerst wird dadurch behoben, dass der Ball in dem Moment herausspringt, in dem er den unteren Bildschirmrand berührt. Um dieses Problem zu beheben, ersetzen Sie den Aufruf removeFromParent durch RemoveEffect. RemoveEffect entfernt den Ball aus der Spielwelt, nachdem der Ball den sichtbaren Spielbereich verlassen hat.

Zweitens wurde durch diese Änderungen das Umgang mit der Kollision zwischen Schläger und Ball behoben. Dieser Code funktioniert sehr zugunsten des Spielers. Solange der Spieler den Ball mit dem Schläger berührt, kehrt der Ball zum oberen Bildschirmrand zurück. Wenn sich das zu nachsichtig anfühlt und du etwas realistischer haben möchtest, ändere die Steuerung, damit das Spiel sich besser an deine Vorstellungen anpassen kann.

Es ist erwähnenswert, wie komplex das velocity-Update ist. Damit wird nicht nur die y-Komponente der Geschwindigkeit umgekehrt, wie es bei den Wandkollisionen der Fall war. Außerdem wird die x-Komponente entsprechend der relativen Position von Schläger und Ball zum Zeitpunkt des Kontakts aktualisiert. Dadurch hat der Spieler mehr Kontrolle darüber, was der Ball tut, aber genau, wie er dem Spieler in keiner Weise mitgeteilt wird, außer durch Spielen.

Jetzt, da Sie einen Schläger haben, mit dem Sie den Ball schlagen können, wäre es praktisch, ein paar Bausteine zu haben, um mit dem Ball zu zerbrechen!

8. Die Wand durchbrechen

Bauen der Bausteine

Um dem Spiel Bausteine hinzuzufügen,

  1. Fügen Sie einige Konstanten so 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>());
    }
  }
}

Mittlerweile sollte Ihnen der größte Teil dieses Codes bekannt sein. In diesem Code wird ein RectangleComponent mit einer Kollisionserkennung und einem typsicheren Verweis auf das Spiel BrickBreaker oben in der Komponentenstruktur verwendet.

Das wichtigste neue Konzept, das mit diesem Code eingeführt wird, ist die Art und Weise, wie der Spieler die Gewinnbedingung erreicht. Die Gewinnbedingungsprüfung fragt die Welt nach Bausteinen ab und bestätigt, dass nur noch einer übrig ist. Dies kann etwas verwirrend sein, da die vorangehende Zeile diesen Baustein vom übergeordneten Element entfernt.

Wichtig ist, dass Sie verstehen, dass das Entfernen von Komponenten ein Befehl in der Warteschlange ist. Nachdem dieser Code ausgeführt wurde, aber vor dem nächsten Tick der Spielwelt, werden die Bausteine entfernt.

Um die Komponente „Brick“ für BrickBreaker zugänglich zu machen, 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';

Füge der Welt Bausteine hinzu

Aktualisieren 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';                                            // 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.
    }
  }
}

Dadurch wird der einzige neue Aspekt eingeführt: ein Schwierigkeitsmodifikator, der die Geschwindigkeit des Balls nach jedem Zusammenstoß des Bausteins erhöht. Dieser einstellbare Parameter muss getestet werden, um die für Ihr Spiel geeignete Schwierigkeitskurve zu finden.

So bearbeiten Sie das Spiel BrickBreaker:

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 wie bisher ausführen, werden alle wichtigen Spielmechaniken angezeigt. Sie könnten das Debugging deaktivieren und als erledigt bezeichnen, aber es scheint etwas zu fehlen.

Ein Screenshot, der „brick_breaker“ mit dem Ball, einem Schläger und den meisten Steinen auf dem Spielbereich zeigt. Jede der Komponenten hat Labels für die Fehlerbehebung.

Wie wäre es mit einem Begrüßungsbildschirm, einem Spiel über dem Bildschirm und vielleicht einem Punktestand? Flutter kann dem Spiel diese Funktionen hinzufügen – und dann werden Sie Ihre Aufmerksamkeit als Nächstes lenken.

9. Das Spiel gewinnen

Wiedergabestatus hinzufügen

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

Zunächst ändern Sie die Spiel- und Komponentendateien, um einen Wiedergabestatus zu implementieren, der angibt, ob und wenn ja, welches Overlay angezeigt werden soll.

  1. Ändere das BrickBreaker-Spiel wie folgt.

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
}

Durch diesen Code ändert sich viel für das Spiel BrickBreaker. Das Hinzufügen der Aufzählung playState ist aufwendig. Dadurch wird erfasst, an welcher Stelle der Spieler am Spiel teilnimmt, spielt oder ob er das Spiel verliert oder gewinnt. Am Anfang der Datei definieren Sie die Aufzählung und instanziieren sie dann als versteckten Zustand mit übereinstimmenden Getter und Setters. Diese Getter und Setter ermöglichen das Ändern von Overlays, wenn die verschiedenen Teile des Spiels den Spielstatus wechseln.

Als Nächstes teilen Sie den Code in onLoad in onLoad und eine neue startGame-Methode auf. Bisher war es nur möglich, ein neues Spiel zu starten, indem man es neu startet. Dank dieser Neuerungen kann der Spieler nun ohne so drastische Maßnahmen ein neues Spiel starten.

Damit der Spieler ein neues Spiel starten kann, haben Sie zwei neue Handler für das Spiel konfiguriert. Sie haben einen Tipp-Handler hinzugefügt und den Tastatur-Handler erweitert, damit der Nutzer in mehreren Modalitäten ein neues Spiel starten kann. Bei einem modellierten Wiedergabestatus ist es sinnvoll, die Komponenten so zu aktualisieren, dass sie einen Übergang des Wiedergabestatus auslösen, wenn der Spieler entweder gewinnt oder verliert.

  1. Ändere 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 ein onComplete-Callback zum RemoveEffect hinzugefügt, der den Wiedergabestatus gameOver auslöst. Dies sollte sich in Ordnung anfühlen, wenn der Spieler zulässt, dass der Ball vom unteren Bildschirmrand entkommen kann.

  1. So bearbeiten Sie die Komponente „Brick“:

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.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 andererseits alle Steine zerstören kann, hat er das Spiel gewonnen. Bildschirm. Gut gemacht, Spieler, gut gemacht!

Flutter-Wrapper hinzufügen

Mit der Flutter-Shell kannst du das Spiel einbetten und Overlays für den Spielstatus hinzufügen.

  1. Erstellen Sie ein widgets-Verzeichnis unter lib/src.
  2. Fügen Sie eine game_app.dart-Datei mit dem folgenden Inhalt hinzu.

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(
        useMaterial3: true,
        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,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Der Großteil der Inhalte in dieser Datei entspricht dem Standardaufbau eines Flutter-Widget-Baums. Zu den spezifischen Teilen für Flame gehört die Verwendung von GameWidget.controlled zum Erstellen und Verwalten der BrickBreaker-Spielinstanz und das neue overlayBuilderMap-Argument für GameWidget.

Die Schlüssel von overlayBuilderMap müssen mit den Overlays übereinstimmen, die der playState-Setter in BrickBreaker hinzugefügt oder entfernt hat. Der Versuch, ein Overlay zu platzieren, das sich nicht auf dieser Karte befindet, führt zu unglücklichen Gesichtern überall in der Welt.

  1. Um diese neue Funktion auf dem Bildschirm zu sehen, ersetze 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 Ihre App auf macOS oder Android ausgerichtet ist, müssen Sie noch eine letzte Optimierung vornehmen, damit google_fonts angezeigt werden kann.

Zugriff auf Schriftarten aktivieren

Internetberechtigung für Android hinzufügen

Für Android müssen Sie die Internetberechtigung hinzufügen. So bearbeiten Sie AndroidManifest.xml:

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. Passen Sie die Datei DebugProfile.entitlements an den folgenden Code an.

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 zum folgenden Code passt

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>

Bei einer unveränderten Ausführung sollten auf allen Plattformen ein Begrüßungs- und ein Spielbildschirm angezeigt werden. Diese Bildschirme mögen etwas simpel sein und es wäre schön, eine Punktzahl zu haben. Überlegen Sie also, was Sie im nächsten Schritt tun werden!

10. Punktzahl beibehalten

Spielstand hinzufügen

In diesem Schritt stellen Sie die Spielpunktzahl dem umgebenden Flutter-Kontext zur Verfügung. In diesem Schritt geben Sie den Status aus dem Flame-Spiel für die Flutter-Statusverwaltung in der Umgebung frei. So kann der Spielcode jedes Mal den Spielstand aktualisieren, wenn der Spieler einen Baustein zerbricht.

  1. Ändere das BrickBreaker-Spiel wie folgt.

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 dem Spiel score hinzufügen, verknüpfen Sie den Status des Spiels mit der Flutter-Statusverwaltung.

  1. Ändere die Brick-Klasse so, dass ein Punkt zum Punktestand hinzugefügt wird, wenn der Spieler die Bausteine 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 gut aussehendes Spiel entwickeln

Jetzt, da du die Punktzahl in Flutter festhalten kannst, ist es an der Zeit, die Widgets so zusammenzustellen, dass alles 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 dank der Leistungsfähigkeit des flutter_animate-Pakets noch schöner, um den Overlay-Bildschirmen etwas Bewegung und Stil zu verleihen.

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

Einen detaillierteren Einblick in die Möglichkeiten von flutter_animate finden Sie im Codelab zum Erstellen von UIs der nächsten Generation in Flutter.

Dieser Code hat sich in der Komponente GameApp stark verändert. Damit ScoreCard auf score zugreifen kann, wandeln Sie es zuerst von StatelessWidget in StatefulWidget um. Zum Hinzufügen der Scorekarte muss eine Column hinzugefügt werden, um die Punktzahl über dem Spiel zu stapeln.

Außerdem hast du das neue OverlayScreen-Widget hinzugefügt, um die Begrüßung, das Spielerlebnis und den Sieg 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(
        useMaterial3: true,
        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.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Jetzt sollten Sie das Spiel auf jeder der sechs Flutter-Zielplattformen ausführen können. Das Spiel sollte in etwa so aussehen.

Ein Screenshot von „brick_breaker“, der den Bildschirm vor dem Spiel zeigt, in dem er aufgefordert wird, auf den Bildschirm zu tippen, um das Spiel zu spielen

Ein Screenshot von „Brick_breaker“, der das Spiel über einen Schläger und einige der Steine zeigt

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