1. Wprowadzenie
Flame to oparty na technologii Flutter silnik gry 2D. Dzięki temu ćwiczeniu w Codelabs dowiesz się, jak stworzyć grę inspirowaną jedną z klasycznych gier wideo z lat 70. Steve'a Wozniaka „Breakout”. Użyjesz komponentów płomienia, aby narysować kij, piłkę i klocki. Wykorzystasz Flame's Effects, aby animować ruch nietoperza i zobaczyć, jak zintegrować Flame z systemem zarządzania stanem Flutter.
Po zakończeniu gra powinna wyglądać jak ten animowany GIF, ale będzie nieco wolniejsza.
Czego się nauczysz
- Jak działają podstawy Flame, zaczynając od
GameWidget
. - Jak korzystać z pętli gry
- Jak działają
Component
Flame. Są podobne doWidget
Fluttera. - Postępowanie w przypadku kolizji.
- Jak używać elementów
Effect
do animowania elementówComponent
. - Jak nakładać
Widget
elementów Flutter na grę z płomieniem. - Jak zintegrować Flame z systemem zarządzania stanem w Flutter.
Co utworzysz
W ramach tego ćwiczenia w programie utworzysz grę 2D przy użyciu technologii Flutter i Flame. Gdy gra się zakończy, gra powinna spełniać te wymagania
- funkcja na wszystkich 6 platformach obsługiwanych przez Flutter: Android, iOS, Linux, macOS, Windows i w przeglądarce.
- Pętla gry Flame pozwala utrzymać prędkość co najmniej 60 klatek na sekundę.
- Dzięki funkcjom Flutter, takim jak pakiet
google_fonts
iflutter_animate
, możesz poczuć się jak w grach zręcznościowych z lat 80.
2. Konfigurowanie środowiska Flutter
Edytujący
Aby uprościć ten moduł, zakładamy, że Twoim środowiskiem programistycznym jest Visual Studio Code (VS Code). Aplikacja VS Code jest bezpłatna i działa na wszystkich głównych platformach. W tym ćwiczeniu z programowania używamy VS Code, ponieważ instrukcje domyślnie obejmują skróty VS Code. Zadania staną się prostsze: „kliknij ten przycisk”. lub „naciśnij ten klawisz, aby wykonać X” zamiast „wykonaj w edytorze odpowiednie działanie, aby wykonać X”.
Możesz używać dowolnego edytora: Android Studio, inne IDE IntelliJ, Emacs, Vim lub Notatnik++. Wszystkie współpracują z platformą Flutter.
Wybierz cel rozwojowy
Flutter tworzy aplikacje na wiele platform. Twoja aplikacja może działać w dowolnym z tych systemów operacyjnych:
- iOS
- Android
- Windows
- macOS
- Linux
- internet
Powszechną praktyką jest wybór jednego systemu operacyjnego na potrzeby programowania. Jest to system operacyjny, w którym Twoja aplikacja działa w trakcie tworzenia aplikacji.
Załóżmy na przykład, że tworzysz aplikację Flutter na laptopie z systemem Windows. Następnie jako cel programu musisz wybrać Androida. Aby zobaczyć podgląd aplikacji, podłącz urządzenie z Androidem do laptopa z systemem Windows przy użyciu kabla USB, a opracowana aplikacja działa na podłączonym urządzeniu z Androidem lub w emulatorze Androida. Jako cel rozwojowy możesz wybrać system Windows, który będzie uruchamiał Twoją aplikację jako aplikację Windows wraz z edytorem.
Może Cię kusić, że Twoim celem programowania może być internet. W trakcie tworzenia aplikacji ma to negatywny wpływ na działanie funkcji Stateful Hot Załaduj ponownie w usłudze Flutter. Obecnie Flutter nie może ponownie wczytywać aplikacji internetowych, jeśli
Wybierz, zanim przejdziesz dalej. Zawsze możesz uruchomić aplikację w innym systemie operacyjnym później. Ustalenie celu rozwoju sprawia, że kolejny krok przebiega sprawniej.
Zainstaluj Flutter
Najnowsze instrukcje instalowania pakietu SDK Flutter znajdziesz na stronie docs.flutter.dev.
Instrukcje na stronie Flutter opisują instalację pakietu SDK, a także narzędzia związane z programowaniem i wtyczki edytora. Na potrzeby tego ćwiczenia w Codelabs zainstaluj następujące oprogramowanie:
- Pakiet SDK Flutter
- Kod w Visual Studio z wtyczką Flutter
- Skompiluj oprogramowanie na potrzeby wybranego środowiska programistycznego. (Musisz mieć Visual Studio, aby kierować reklamy na system Windows, lub Xcode, aby kierować reklamy na system macOS lub iOS).
W następnej sekcji utworzysz pierwszy projekt Flutter.
Niektóre z tych pytań i odpowiedzi (z StackOverflow) mogą okazać się pomocne w rozwiązywaniu problemów.
Najczęstsze pytania
- Jak znaleźć ścieżkę pakietu SDK Flutter?
- Co zrobić, jeśli nie można znaleźć polecenia Flutter?
- Jak rozwiązać problem z komunikatem „Czekam na kolejne polecenie Flutter, aby zwolnić blokadę uruchamiania”
- Jak mogę poinformować Flutter, gdzie znajduje się moja instalacja pakietu Android SDK?
- Jak rozwiązać problem z błędem Javy podczas uruchamiania
flutter doctor --android-licenses
? - Co zrobić, jeśli nie znaleziono narzędzia Android
sdkmanager
? - Co zrobić, jeśli brakuje komponentu
cmdline-tools
? ? - Jak uruchomić CocoaPods w Apple Silicon (M1)?
- Jak wyłączyć automatyczne formatowanie podczas zapisywania w narzędziu VS Code?
3. Utwórz projekt
Tworzenie pierwszego projektu Flutter
Wymaga to otwarcia VS Code i utworzenia szablonu aplikacji Flutter w wybranym przez Ciebie katalogu.
- Uruchom Visual Studio Code.
- Otwórz paletę poleceń (
F1
,Ctrl+Shift+P
lubShift+Cmd+P
), a następnie wpisz „flutter new”. Wybierz wtedy polecenie Flutter: nowy projekt.
- Wybierz Empty Application (Pusta aplikacja). Wybierz katalog, w którym chcesz utworzyć projekt. Powinien to być dowolny katalog, który nie wymaga podwyższonych uprawnień ani nie ma spacji w ścieżce. Przykładem może być katalog główny lub adres
C:\src\
.
- Nazwij projekt
brick_breaker
. W pozostałej części tego ćwiczenia w Codelabs zakładamy, że Twoja aplikacja nazywa siębrick_breaker
.
Flutter utworzy teraz folder projektu, a VS Code otworzy go. Zastąpisz teraz zawartość dwóch plików podstawowym scaffrem aplikacji.
Kopiuj & Wklej początkową aplikację
Spowoduje to dodanie do aplikacji przykładowego kodu podanego w tym ćwiczeniu.
- W lewym panelu VS Code kliknij Explorer i otwórz plik
pubspec.yaml
.
- Zamień zawartość tego pliku na taką:
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
Plik pubspec.yaml
zawiera podstawowe informacje o aplikacji, takie jak jej bieżąca wersja, zależności oraz zasoby, z którymi zostanie ona wysłana.
- Otwórz plik
main.dart
w katalogulib/
.
- Zamień zawartość tego pliku na taką:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Uruchom ten kod, aby sprawdzić, czy wszystko działa. Powinno wyświetlić się nowe okno z tylko pustym czarnym tłem. Największa na świecie gra wideo renderuje się teraz z szybkością 60 kl./s
4. Utwórz grę
Poznaj więcej możliwości
Rozgrywka w dwóch wymiarach (2D) wymaga obszaru zabaw. Zbudujesz obszar o konkretnych wymiarach, a potem użyj go do określenia wymiarów innych aspektów gry.
Współrzędne można rozmieścić na różne sposoby w obszarze gry. Zgodnie z jedną konwencją możesz mierzyć kierunek od środka ekranu, a punkt początkowy (0,0)
znajduje się na środku ekranu. Wartości dodatnie powodują przenoszenie elementów w prawo wzdłuż osi X i w górę wzdłuż osi Y. Ten standard ma zastosowanie do większości współczesnych gier, zwłaszcza gdy są to gry złożone z 3 wymiarów.
Podczas tworzenia pierwotnej gry w podgrupach powołano się na ustawienie źródła w lewym górnym rogu. Dodatnia kierunek x pozostała taka sama, ale y została odwrócona. Kierunek osi X dodatniej był prawidłowy, a y był w dół. Aby wiernie odnosić się do epoki, początkiem tej gry jest lewy górny róg.
Utwórz plik o nazwie config.dart
w nowym katalogu o nazwie lib/src
. W kolejnych krokach plik ten uzyska więcej stałych wartości.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Ta gra będzie miała 820 pikseli szerokości i 1600 pikseli wysokości. Obszar gry dopasowuje się do okna, w którym jest wyświetlany, ale wszystkie elementy dodane do ekranu mają odpowiednią wysokość i szerokość.
Tworzenie obszaru PlayArea
W grze Breakout piłka odbija się od ścian strefy zabaw. Aby uwzględniać kolizje, potrzebujesz najpierw komponentu PlayArea
.
- Utwórz plik o nazwie
play_area.dart
w nowym katalogu o nazwielib/src/components
. - Dodaj do tego pliku te elementy.
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);
}
}
Tam, gdzie Flutter ma Widget
, płomień ma Component
. W aplikacjach Flutter można tworzyć drzewa widżetów, a gry Flame polegają na utrzymywaniu drzew złożonych z komponentów.
Istnieje ciekawa różnica między Flutterem a Płomieniem. Drzewo widżetów Flutter to opis tymczasowy, który służy do aktualizowania trwałej i zmiennej warstwy RenderObject
. Komponenty Flame są trwałe i zmienne, a programista będzie ich używać w swoich systemach symulacji.
Komponenty Flame są zoptymalizowane pod kątem wyrażania mechaniki gry. To ćwiczenie w programowaniu rozpocznie się od pętli gry, którą przedstawimy w następnym kroku.
- Aby kontrolować bałagan, dodaj plik ze wszystkimi komponentami w tym projekcie. Utwórz plik
components.dart
w usłudzelib/src/components
i dodaj do niego poniższą treść.
lib/src/components/components.dart
export 'play_area.dart';
Dyrektywa export
odgrywa odwrotną rolę import
. Deklaruje funkcje udostępniane przez ten plik po zaimportowaniu go do innego pliku. W miarę dodawania nowych komponentów w kolejnych krokach będziesz mieć więcej wpisów w tym pliku.
Stwórz grę „Płomień”
Aby zniwelować czerwone prążki z poprzedniego kroku, wyprowadź nową podklasę dla funkcji FlameGame
Flame.
- Utwórz w pliku
lib/src
plik o nazwiebrick_breaker.dart
i dodaj ten kod.
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());
}
}
Ten plik koordynuje działania w grze. Podczas tworzenia instancji gry ten kod konfiguruje grę pod kątem renderowania o stałej rozdzielczości. Gra zmienia rozmiar, aby wypełnić ekran, na którym się znajduje, i w razie potrzeby dodaje listy letterbox.
Pokazujesz szerokość i wysokość gry, aby elementy podrzędne, np. PlayArea
, mogły ustawić się w odpowiednim rozmiarze.
W metodzie zastąpionej onLoad
kod wykonuje 2 działania.
- Konfiguruje lewy górny róg jako element zakotwiczenia wizjera. Domyślnie wizjer wykorzystuje środek obszaru jako reklamę zakotwiczoną w przypadku elementu
(0,0)
. - Dodaje
PlayArea
do sekcjiworld
. Świat reprezentuje świat gier. Wyświetla ona wszystkie swoje elementy podrzędne w ramach przekształcenia widokuCameraComponent
.
Pokaż grę na ekranie
Aby zobaczyć wszystkie zmiany wprowadzone w tym kroku, dodaj do pliku lib/main.dart
podane niżej zmiany.
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));
}
Po wprowadzeniu tych zmian uruchom ponownie grę. Gra powinna wyglądać podobnie do ilustracji poniżej.
W następnym kroku dodasz do świata piłkę i zacznij się ruszać.
5. Wyświetl piłkę
Utwórz komponent kulki
Umieszczenie ruchomej piłki na ekranie wymaga utworzenia kolejnego komponentu i dodania go do świata gry.
- Zmodyfikuj zawartość pliku
lib/src/config.dart
w podany niżej sposób.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
W tym ćwiczeniu w Codelabs wzorzec definiowania stałych nazwanych jako wartości pochodnych będzie zwracany wiele razy. Dzięki temu możesz zmodyfikować gameWidth
i gameHeight
najwyższego poziomu, aby sprawdzić, jak w wyniku tych zmian zmieni się wygląd i sposób działania gry.
- Utwórz komponent
Ball
w pliku o nazwieball.dart
wlib/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;
}
}
Wcześniej kształt PlayArea
został zdefiniowany przez Ciebie za pomocą elementu RectangleComponent
, więc oznacza to, że istnieje więcej kształtów. CircleComponent
, podobnie jak RectangleComponent
, wywodzi się od PositionedComponent
, więc możesz umieścić piłkę na ekranie. Co ważniejsze, jej pozycję można zmienić.
Ten komponent przedstawia pojęcie velocity
, czyli zmiany pozycji w czasie. Prędkość to obiekt Vector2
, ponieważ prędkość to zarówno prędkość, jak i kierunek. Aby zaktualizować położenie, zastąp metodę update
, którą silnik gry wywołuje w przypadku każdej klatki. dt
to czas między poprzednią a tą klatką. Dzięki temu możesz dostosować urządzenie do takich czynników jak różna liczba klatek (60 lub 120 Hz) lub długie klatki z powodu zbyt dużej mocy obliczeniowej.
Zwróć szczególną uwagę na aktualizację position += velocity * dt
. W ten sposób wdrażasz aktualizację dyskretnej symulacji ruchu w czasie.
- Aby uwzględnić komponent
Ball
na liście komponentów, zmodyfikuj pliklib/src/components/components.dart
w ten sposób.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Dodawanie piłki do świata
Masz piłkę. Umieśćmy ją na całym świecie i skonfigurujmy, aby przesuwała się po placu zabaw.
Zmodyfikuj plik lib/src/brick_breaker.dart
w następujący sposób.
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.
}
}
Ta zmiana dodaje komponent Ball
do world
. Aby ustawić position
kuli w środku obszaru wyświetlania, kod najpierw zmniejsza rozmiar gry, ponieważ funkcja Vector2
zawiera przeciążenia operatorów (*
i /
) w celu skalowania Vector2
według wartości skalarnej.
Ustawienie velocity
piłki wymaga bardziej złożonych działań. Celem jest przesuwanie piłki w losowym kierunku z rozsądną prędkością. Wywołanie metody normalized
tworzy obiekt Vector2
ustawiony w tym samym kierunku co oryginalny obiekt Vector2
, ale przeskalowany w dół do odległości 1. Dzięki temu prędkość piłki jest stała niezależnie od kierunku, w którym idzie. Prędkość piłki jest zwiększana do 1/4 wysokości gry.
Aby zapewnić prawidłowe działanie tych wartości, należy przeprowadzić kilka iteracji (tzw. „testy play” w branży).
Ostatni wiersz włącza ekran debugowania, który dodaje do niego dodatkowe informacje ułatwiające debugowanie.
Po uruchomieniu gry obraz powinien wyglądać podobnie do tego:
Zarówno komponent PlayArea
, jak i Ball
zawierają informacje debugowania, ale matowe tło obcina numery elementu PlayArea
. Wszystkie informacje debugowania są wyświetlane, ponieważ włączono debugMode
dla całego drzewa komponentów. Możesz też włączyć debugowanie tylko dla wybranych komponentów.
Po kilkukrotnym ponownym uruchomieniu gry możesz zauważyć, że piłka nie uderza w ściany tak, jak powinna. Aby uzyskać ten efekt, musisz dodać funkcję wykrywania kolizji, która zostanie wykonana w następnym kroku.
6. Odwróć się
Dodaj wykrywanie kolizji
Wykrywanie kolizji to funkcja, dzięki której gra rozpoznaje, kiedy 2 obiekty ze sobą stykają się.
Aby dodać do gry funkcję wykrywania kolizji, dodaj składankę HasCollisionDetection
do gry BrickBreaker
w sposób pokazany poniżej.
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;
}
}
Ta funkcja śledzi pola działań komponentów i uruchamia wywołania zwrotne w przypadku kolizji przy każdym kliknięciu w grze.
Aby zacząć wypełniać pola działań w grze, zmień komponent PlayArea
w podany niżej sposób.
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);
}
}
Dodanie komponentu RectangleHitbox
jako elementu podrzędnego względem RectangleComponent
spowoduje utworzenie pola działania do wykrywania kolizji pasującego do rozmiaru komponentu nadrzędnego. Na potrzeby funkcji RectangleHitbox
dostępny jest konstruktor fabryczny o nazwie relative
, który przydaje się, gdy chcesz użyć pola trafienia, które jest mniejsze lub większe niż komponent nadrzędny.
Odrzuć piłkę
Jak dotąd dodanie funkcji wykrywania kolizji nie wpłynęło na rozgrywkę. Zmienia się po zmodyfikowaniu komponentu Ball
. To zachowanie piłki musi się zmienić, gdy zderza ona z PlayArea
.
Zmodyfikuj komponent Ball
w ten sposób.
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.
}
W tym przykładzie wprowadzono dużą zmianę wraz z dodaniem wywołania zwrotnego onCollisionStart
. System wykrywania kolizji dodany do funkcji BrickBreaker
we wcześniejszym przykładzie wywołuje to wywołanie zwrotne.
Najpierw kod sprawdza, czy Ball
zderzył się z PlayArea
. Na razie wydaje się to niepotrzebne, ponieważ w świecie gry nie ma żadnych innych elementów. To zmieni się w następnym kroku, gdy dodasz do świata nietoperza. Następnie dodaje również warunek else
, który ma być obsłużony, gdy piłka zderza się z obiektami, które nie są kijami. Przypominamy o przestrzeganiu wszystkich zasad.
Kiedy piłka zderza się z dolną ścianą, po prostu znika z powierzchni gry, wciąż pozostając w widoku. W przyszłości będziesz zajmować się tym artefaktem, wykorzystując moc Efektów Płomienia.
Teraz gdy piłka zderza się ze ścianami gry, dobrze byłoby dać graczowi kij, aby uderzyć piłkę...
7. Uderzenie piłką
Stwórz nietoperza
Aby dodać kij, który zapewni piłkę podczas gry,
- Wstaw stałe w pliku
lib/src/config.dart
w ten sposób.
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.
Stałe batHeight
i batWidth
nie wymagają wyjaśnień. Z kolei stała batStep
wymaga wyjaśnienia. Aby wejść w interakcję z piłką w grze, gracz może przeciągnąć kij myszą lub palcem (w zależności od platformy) albo użyć klawiatury. Stała batStep
określa, jak daleko odbijają się kroki każdego naciśnięcia klawisza strzałki w lewo lub w prawo.
- Zdefiniuj klasę komponentu
Bat
w ten sposób.
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),
));
}
}
Ten komponent wprowadza kilka nowych możliwości.
Po pierwsze komponent Bat to PositionComponent
, a nie RectangleComponent
ani CircleComponent
. Oznacza to, że ten kod musi wyrenderować obiekt Bat
na ekranie. W tym celu zastępuje wywołanie zwrotne render
.
Jeśli spojrzysz z bliska w wywołanie funkcji canvas.drawRRect
(zaokrąglony prostokąt), możesz zadać sobie pytanie: „Gdzie jest prostokąt?” Offset.zero & size.toSize()
wykorzystuje przeciążenie operator &
w klasie Offset
dart:ui
, która tworzy Rect
. Ten skrót może być z początkiem zdezorientowany, ale w przypadku kodów Flutter i Flome na niższym poziomie będzie się pojawiać często.
Po drugie komponent Bat
można przeciągać palcem lub myszą w zależności od platformy. Aby zaimplementować tę funkcję, musisz dodać składankę DragCallbacks
i zastąpić zdarzenie onDragUpdate
.
Ostatni: komponent Bat
musi reagować na sterowanie za pomocą klawiatury. Funkcja moveBy
umożliwia innemu kodowi polecenie przesuwania w lewo lub w prawo o określoną liczbę wirtualnych pikseli. Ta funkcja wprowadza nową możliwość silnika gry Flame: Effect
. Gdy dodasz obiekt MoveToEffect
jako element podrzędny tego komponentu, gracz będzie widzieć kij animowany w nowym położeniu. W Płomieniu dostępna jest kolekcja Effect
utworów, dzięki którym możesz uzyskać różne efekty.
Argumenty konstruktora efektu zawierają odwołanie do metody pobierania game
. Dlatego uwzględniasz składankę HasGameReference
do tej klasy. Ta kombinacja dodaje do tego komponentu akcesor game
zapewniający dostęp do instancji BrickBreaker
na górze drzewa komponentów.
- Aby udostępnić
Bat
użytkownikowiBrickBreaker
, zaktualizuj pliklib/src/components/components.dart
w następujący sposób.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Nie daj się wciągnąć w świat
Aby dodać komponent Bat
do świata gier, zaktualizuj BrickBreaker
w ten sposób.
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
}
Dodanie składanki KeyboardEvents
i zastąpiona metoda onKeyEvent
obsługuje wprowadzanie tekstu z klawiatury. Wycofaj dodany wcześniej kod, aby przesunąć kij o odpowiednią ilość kroków.
Pozostały fragment dodanego kodu dodaje kij do świata gry we właściwym położeniu i proporcjach. Ujawnienie wszystkich tych ustawień w tym pliku ułatwia dostosowanie względnego rozmiaru kija i piłki do stylu gry.
Jeśli zaczniesz grać w tym momencie, widzisz, że możesz poruszyć piłkę, by przechwycić piłkę, ale nie uzyskasz widocznej odpowiedzi poza zapisem debugowania zapisanym w kodzie wykrywania kolizji urządzenia Ball
.
Teraz czas to naprawić. Zmodyfikuj komponent Ball
w ten sposób.
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');
}
}
}
Te zmiany w kodzie rozwiązują 2 osobne problemy.
Przede wszystkim eliminuje pojawianie się kulki w momencie dotknięcia dolnej części ekranu. Aby rozwiązać ten problem, zastąp połączenie typu removeFromParent
elementem RemoveEffect
. RemoveEffect
usuwa piłkę ze świata gry po tym, jak pozwala jej opuścić widoczny obszar zabaw.
Po drugie, te zmiany służą do rozwiązywania problemów w przypadku kolizji między kijem a piłką. Taki kod obsługi działa bardzo na korzyść gracza. Gdy gracz dotknie piłki kijkiem, piłka wraca na górę ekranu. Jeśli wydaje Ci się to zbyt wybaczające i potrzebujesz czegoś bardziej realistycznego, zmień sposób obsługi tak, aby gra lepiej się pasowała.
Warto zwrócić uwagę na złożoność aktualizacji velocity
. Odwraca ona nie tylko składnik y
prędkości, jak miało to miejsce w przypadku zderzeń ścian. Aktualizuje też komponent x
w sposób zależny od względnego położenia kija i piłki w momencie kontaktu. Dzięki temu gracz ma większą kontrolę nad tym, co robi piłka, ale nie przekazuje mu żadnych informacji poza przebiegiem gry.
Skoro masz już kij, którym możesz odbić piłkę, dobrze byłoby mieć klocki do złamania.
8. Zburz mur
Tworzenie klocków
Aby dodać klocki do gry:
- Wstaw stałe w pliku
lib/src/config.dart
w ten sposób.
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.
- Wstaw komponent
Brick
w ten sposób.
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>());
}
}
}
Na razie większość kodu powinna być już znajoma. Ten kod wykorzystuje identyfikator RectangleComponent
zawierający zarówno wykrywanie kolizji, jak i bezpieczne odniesienie do gry BrickBreaker
na górze drzewa komponentów.
Najważniejszą nową koncepcją w tym kodzie jest sposób, w jaki gracz osiąga warunek wygranej. Kontrola warunku zwycięstwa wysyła zapytania do świata w poszukiwaniu klocków i potwierdza, że pozostanie tylko jedna z nich. Może to być nieco mylące, ponieważ poprzedni wiersz usuwa ten klocek z elementu nadrzędnego.
Pamiętaj, że usunięcie komponentu jest poleceniem w kolejce. Usuwa klocek po uruchomieniu kodu, ale przed kolejnym kliknięciem w świecie gry.
Aby udostępnić komponent Brick
aplikacji BrickBreaker
, edytuj lib/src/components/components.dart
w ten sposób.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Dodaj klocki do świata
Zaktualizuj komponent Ball
w ten sposób.
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.
}
}
}
Jest to jedyny nowy aspekt – modyfikator trudności, który zwiększa prędkość piłki po każdym zderzeniu klocków. Ten parametr z możliwością regulacji trzeba przetestować, aby znaleźć odpowiednią krzywą poziomu trudności odpowiednią dla Twojej gry.
Edytuj grę BrickBreaker
w ten sposób.
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;
}
}
Jeśli uruchomisz grę w obecnej wersji, zobaczysz wszystkie najważniejsze elementy mechaniki gry. Można wyłączyć debugowanie i wywołać to zadanie, ale wygląda na to, że coś brakuje.
Ekran powitalny, gra na ekranie i może wynik? Flutter może dodać te funkcje do gry i to właśnie na tym musisz się skupić.
9. Wygraj
Dodaj stany gry
W tym kroku umieszczasz grę Płomień wewnątrz otoki Flutter, a następnie dodajesz nakładki Flutter, które wyświetlają się na ekranach powitalnych, po zakończeniu gry i wygranych.
Najpierw musisz zmodyfikować pliki gry i komponentów, aby zaimplementować stan gry, który określa, czy ma być wyświetlana nakładka, a jeśli tak, to jaką.
- Zmodyfikuj grę
BrickBreaker
w ten sposób.
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
}
Ten kod zmienia znaczną część gry BrickBreaker
. Dodanie wyliczenia playState
wymaga dużo pracy. Odzwierciedlają one moment, w którym gracz wchodzi do gry, gra, przegrywa lub wygrywa. Na początku pliku możesz zdefiniować wyliczenie, a następnie utworzyć je jako instancję ukrytą z pasującymi do niego pobierającymi i ustawiającymi. Te metody pobierania i seterów umożliwiają modyfikowanie nakładek, gdy różne elementy gry wywołują zmianę stanu gry.
Następnie podzieliłeś kod w onLoad
na metodę onLoad i nową metodę startGame
. Wcześniej można było rozpocząć nową grę tylko przez jej ponowne uruchomienie. Dzięki tym nowościom gracze mogą teraz rozpocząć nową grę bez tak drastycznych scen.
Aby umożliwić graczowi rozpoczęcie nowej gry, zostały skonfigurowane 2 nowe moduły obsługi. Został dodany moduł obsługi dotyku i rozszerzony moduł obsługi klawiatury, aby umożliwić użytkownikowi uruchamianie nowej gry w różnych modalnościach. Przy modelowaniu stanu gry warto zaktualizować komponenty tak, aby zmieniały stan gry, gdy gracz wygra lub przegra.
- Zmodyfikuj komponent
Ball
w ten sposób.
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);
}
}
}
Ta niewielka zmiana dodaje wywołanie zwrotne onComplete
do RemoveEffect
, które aktywuje stan odtwarzania gameOver
. Powinno to wyglądać dobrze, jeśli zawodnik pozwala piłce uciec spod ekranu.
- Zmodyfikuj komponent
Brick
w ten sposób.
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>());
}
}
}
Jeśli gracz zdobędzie wszystkie klocki, zwycięży w grze. ekranu. Świetna robota, gracz, dobra robota!
Dodaj opakowanie Flutter
Aby zapewnić miejsce na osadzenie gry i nakładki stanu gry, dodaj powłokę Flutter.
- Utwórz katalog
widgets
w domenielib/src
. - Dodaj plik
game_app.dart
i wstaw do niego poniższą treść.
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,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
Większość treści w tym pliku jest tworzona zgodnie ze standardową kompilacją drzewa widżetów Flutter. Częścią charakterystyczną dla Flame jest użycie GameWidget.controlled
do utworzenia instancji gry BrickBreaker
i zarządzanie nią, a także nowego argumentu overlayBuilderMap
w GameWidget
.
Klucze tego elementu overlayBuilderMap
muszą być zgodne z nakładkami dodanymi lub usuniętymi przez ustawienia playState
w elemencie BrickBreaker
. Próba ustawienia nakładki, której nie ma na tej mapie, powoduje wyświetlanie niezadowolonych twarzy dookoła.
- Aby ta nowa funkcja była widoczna na ekranie, zastąp plik
lib/main.dart
następującą treścią.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Jeśli uruchomisz ten kod w systemie iOS, Linux, Windows lub na stronie internetowej, odpowiednie dane wyjściowe pojawią się w grze. Jeśli kierujesz swoją aplikację na system macOS lub Android, musisz wprowadzić ostatnie poprawki, aby włączyć wyświetlanie treści google_fonts
.
Włączanie dostępu do czcionek
Dodawanie uprawnień do internetu na urządzeniu z Androidem
W przypadku Androida musisz dodać uprawnienia do korzystania z internetu. Edytuj AndroidManifest.xml
w następujący sposób.
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>
Edytowanie plików uprawnień w systemie macOS
W systemie macOS musisz edytować 2 pliki.
- Zmodyfikuj plik
DebugProfile.entitlements
, tak aby pasował do tego kodu.
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>
- Zmodyfikuj plik
Release.entitlements
, tak aby pasował do tego kodu
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>
Po uruchomieniu tej metody na wszystkich platformach powinien wyświetlać się ekran powitalny oraz ekran z informacją o rozgrywce lub wygranej. Te ekrany mogą być nieco uproszczone, ale dobrze byłoby poznać wynik. Odgadnij, co będziesz robić w następnym kroku.
10. Zachowaj wynik
Dodaj wynik do gry
W tym kroku przedstawisz wynik gry w kontekście technologii Flutter. W tym kroku ujawnisz stan z gry Flame do powiązanego zarządzania stanem Flutter. Dzięki temu kod gry aktualizuje wynik za każdym razem, gdy gracz złamuje klocek.
- Zmodyfikuj grę
BrickBreaker
w ten sposób.
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);
}
Dodając do gry element score
, musisz powiązać jej stan z zarządzaniem stanami Flutter.
- Zmień klasę
Brick
, by dodać punkt do wyniku, gdy gracz zbija klocki.
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>());
}
}
}
Stwórz dobrze wyglądającą grę
Skoro masz już wynik w technologii Flutter, czas połączyć widżety, aby wyglądały dobrze.
- Utwórz
score_card.dart
wlib/src/widgets
i dodaj następujące elementy.
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!,
),
);
},
);
}
}
- Utwórz
overlay_screen.dart
wlib/src/widgets
i dodaj ten kod.
Dzięki temu wygląd nakładek może być bardziej dopracowany dzięki możliwościom pakietu flutter_animate
.
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),
],
),
);
}
}
Aby dowiedzieć się więcej o możliwościach flutter_animate
, zapoznaj się z ćwiczeniami z programowania dotyczącymi tworzenia interfejsów nowej generacji w Flutter.
Ten kod bardzo się zmienił w komponencie GameApp
. Aby umożliwić usłudze ScoreCard
dostęp do elementu score
, musisz przekonwertować go z wartości StatelessWidget
na StatefulWidget
. Dodanie karty wyników wymaga dodania elementu Column
, aby nałożyć wynik nad grą.
Po drugie, aby ulepszyć powitanie, grę i wygrane nagrody, dodaliśmy nowy widżet OverlayScreen
.
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.
),
),
),
),
),
);
}
}
Teraz możesz zagrać w tę grę na dowolnej z 6 platform docelowych Flutter. Gra powinna wyglądać mniej więcej tak:
11. Gratulacje
Gratulacje, udało Ci się utworzyć grę w grze Flutter i Płomień.
Udało Ci się stworzyć grę za pomocą silnika gry Flame 2D i umieścić ją w opakowaniu Flutter. Udało Ci się wykorzystać efekt Flame's Effects do animowania i usuwania komponentów. Udało Ci się wykorzystać pakiety Google Fonts i Flutter Animate, dzięki czemu gra wygląda dobrze.
Co dalej?
Zapoznaj się z tymi ćwiczeniami z programowania...
- Tworzenie interfejsów nowej generacji w Flutter
- Zmiana aplikacji Flutter z nudnej na piękną
- Dodawanie zakupów w aplikacji do aplikacji Flutter