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.
Lerninhalte
- Grundlagen von Flame, beginnend mit
GameWidget
. - So verwenden Sie einen Game-Loop.
- So funktionieren die
Component
s von Flame. Sie ähneln denWidget
s von Flutter. - Umgang mit Kollisionen
Effect
s zum Animieren vonComponent
s verwenden- So legen Sie Flutter-
Widget
s ü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
undflutter_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.
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.
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:
- Flutter SDK
- Visual Studio Code mit dem Flutter-Plug-in
- 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
- Wie finde ich den Flutter SDK-Pfad?
- Was kann ich tun, wenn der Flutter-Befehl nicht gefunden wird?
- Wie behebe ich das Problem „Waiting for another flutter command to release the startup lock“?
- Wie teile ich Flutter mit, wo sich meine Android SDK-Installation befindet?
- Wie gehe ich mit dem Java-Fehler um, der beim Ausführen von
flutter doctor --android-licenses
auftritt? - Wie gehe ich vor, wenn das Android
sdkmanager
-Tool nicht gefunden wird? - Wie gehe ich mit dem Fehler „Die Komponente
cmdline-tools
fehlt“ um? - Wie führe ich CocoaPods auf Apple Silicon (M1) aus?
- Wie deaktiviere ich die automatische Formatierung beim Speichern in VS Code?
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.
- Starten Sie Visual Studio Code.
- Öffnen Sie die Befehlspalette (
F1
oderCtrl+Shift+P
oderShift+Cmd+P
) und geben Sie „flutter new“ ein. Wählen Sie den Befehl Flutter: New Project aus, wenn er angezeigt wird.
- 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\
.
- Benennen Sie Ihr Projekt mit
brick_breaker
. Im weiteren Verlauf dieses Codelabs wird davon ausgegangen, dass Sie Ihre Appbrick_breaker
genannt haben.
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.
- Klicken Sie im linken Bereich von VS Code auf Explorer und öffnen Sie die Datei
pubspec.yaml
.
- 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.
- Öffnen Sie die Datei
main.dart
im Verzeichnislib/
.
- 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));
}
- 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!
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.
- Erstellen Sie eine Datei mit dem Namen
play_area.dart
in einem neuen Verzeichnis mit dem Namenlib/src/components
. - 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 Widget
s hat, hat Flame Component
s. 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.
- Um Unordnung zu vermeiden, fügen Sie eine Datei mit allen Komponenten in diesem Projekt hinzu. Erstellen Sie eine
components.dart
-Datei inlib/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.
- Erstellen Sie im Ordner
lib/src
eine Datei mit dem Namenbrick_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.
- 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. - Fügt
PlayArea
zuworld
hinzu. Die Welt repräsentiert die Spielwelt. Alle untergeordneten Elemente werden durch die Ansichtstransformation vonCameraComponent
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:
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.
- 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.
- Erstellen Sie die Komponente
Ball
in einer Datei mit dem Namenball.dart
inlib/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.
- Wenn Sie die Komponente
Ball
in die Liste der Komponenten aufnehmen möchten, bearbeiten Sie die Dateilib/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:
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:
- 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.
- 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 Rect
s 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 Effect
s 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.
- Damit die
Bat
fürBrickBreaker
verfügbar ist, aktualisieren Sie die Dateilib/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:
- 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.
- 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.
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.
- Ä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.
- Ä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.
- 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.
- Erstellen Sie unter
lib/src
ein Verzeichniswidgets
. - 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.
- 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.
- 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>
- 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.
- Ä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.
- Ä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.
- Erstellen Sie
score_card.dart
inlib/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!,
),
);
},
);
}
}
- Erstellen Sie
overlay_screen.dart
inlib/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.
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:
- Benutzeroberflächen der nächsten Generation mit Flutter erstellen
- Flutter-Apps von langweilig zu schön machen
- In-App-Käufe zur Flutter-App hinzufügen