Informacje o tym ćwiczeniu (w Codelabs)
1. Wprowadzenie
Flame to silnik gier 2D oparty na Flutterze. W tym Codelab utworzysz grę inspirowaną jedną z klasycznych gier wideo z lat 70., Breakout Steve'a Wozniaka. Do narysowania pałki, piłki i cegieł użyjesz komponentów Flame. Użyjesz efektów Flame, aby animować ruch nietoperza, i zobaczysz, jak zintegrować Flame z systemem zarządzania stanem Flutter.
Gdy skończysz, gra powinna wyglądać jak ten animowany GIF, choć nieco wolniej.
Czego się nauczysz
- Podstawy działania Flame, zaczynając od
GameWidget
. - Jak używać pętli gry.
- Jak działają
Component
w Flame Są one podobne doWidget
w Flutterze. - Jak obsługiwać kolizje.
- Jak używać
Effect
do animowaniaComponent
. - Jak nałożyć
Widget
Fluttera na grę Flame - Jak zintegrować Flame z systemem zarządzania stanem Fluttera.
Co utworzysz
W tym ćwiczeniu z programowania utworzysz grę 2D przy użyciu Fluttera i Flame. Po zakończeniu tworzenia gra powinna spełniać te wymagania:
- działać na wszystkich 6 platformach obsługiwanych przez Fluttera: Android, iOS, Linux, macOS, Windows i internet;
- Utrzymaj co najmniej 60 FPS za pomocą pętli gry Flame.
- Użyj funkcji Flutter, takich jak pakiet
google_fonts
iflutter_animate
, aby odtworzyć klimat gier z automatów z lat 80.
2. Konfigurowanie środowiska Flutter
Edytor
Aby uprościć to ćwiczenie, zakładamy, że Visual Studio Code (VS Code) jest Twoim środowiskiem programistycznym. VS Code jest bezpłatny i działa na wszystkich głównych platformach. W tym ćwiczeniu używamy programu VS Code, ponieważ instrukcje odnoszą się do skrótów klawiszowych w tym edytorze. Zadania stają się prostsze: „kliknij ten przycisk” lub „naciśnij ten klawisz, aby wykonać X” zamiast „wykonaj odpowiednie działanie w edytorze, aby wykonać X”.
Możesz użyć dowolnego edytora: Android Studio, innych środowisk IntelliJ, Emacsa, Vim lub Notepad++. Wszystkie działają z Flutterem.
Wybieranie celu rozwoju
Flutter tworzy aplikacje na wiele platform. Aplikacja może działać na dowolnym z tych systemów operacyjnych:
- iOS
- Android
- Windows
- macOS
- Linux
- internet
Zwykle wybiera się jeden system operacyjny jako docelowy system docelowy. To system operacyjny, na którym aplikacja działa podczas tworzenia.
Załóżmy na przykład, że do tworzenia aplikacji Flutter używasz laptopa z systemem Windows. Następnie jako urządzenie docelowe wybierzesz Androida. Aby wyświetlić podgląd aplikacji, podłącz urządzenie z Androidem do laptopa z systemem Windows za pomocą kabla USB. Aplikacja w trakcie tworzenia będzie działać na tym urządzeniu lub na emulatorze Androida. Możesz wybrać system Windows jako docelowy system operacyjny, w którym aplikacja w trakcie tworzenia będzie działać jako aplikacja na Windowsa obok edytora.
Możesz mieć pokusę, aby wybrać internet jako cel rozwoju. Ma to jednak wadę podczas tworzenia: tracisz funkcję Stateful Hot Reload w Flutterze. Flutter nie obsługuje obecnie ponownego wczytywania aplikacji internetowych na gorąco.
Zanim przejdziesz dalej, wybierz opcję. Zawsze możesz uruchomić aplikację na innych systemach operacyjnych. Wybór celu rozwoju ułatwia wykonanie następnego kroku.
Instalowanie Fluttera
Najnowsze instrukcje instalacji pakietu SDK Flutter znajdziesz na stronie docs.flutter.dev.
Instrukcje na stronie Flutter obejmują instalację pakietu SDK oraz narzędzi związanych z docelowym środowiskiem programowania i wtyczek do edytora. W ramach tego ćwiczenia zainstaluj te programy:
- Flutter SDK
- Visual Studio Code z wtyczką Flutter
- Kompilator dla wybranego celu programowania. (do kierowania reklam na system Windows potrzebujesz Visual Studio, a do kierowania na system macOS lub iOS – Xcode).
W następnej sekcji utworzysz swój pierwszy projekt Flutter.
Jeśli napotkasz problemy, możesz znaleźć pomoc w tych pytaniach i odpowiedziach (z StackOverflow).
Najczęstsze pytania
- Jak znaleźć ścieżkę do pakietu SDK Flutter?
- Co zrobić, jeśli polecenie Flutter nie zostanie znalezione?
- Jak rozwiązać problem „Waiting for another flutter command to release the startup lock”?
- Jak wskazać Flutterowi lokalizację pakietu Android SDK?
- Jak rozwiązać problem z błędem Java podczas uruchamiania
flutter doctor --android-licenses
? - Co zrobić, jeśli nie mogę znaleźć narzędzia na urządzeniu z Androidem
sdkmanager
? - Jak rozwiązać problem z błędem „Brak komponentu
cmdline-tools
”? - Jak uruchomić CocoaPods na Apple Silicon (M1)?
- Jak wyłączyć automatyczne formatowanie podczas zapisywania w VS Code?
3. Utwórz projekt
Tworzenie pierwszego projektu Flutter
Polega to na otwarciu VS Code i utworzeniu szablonu aplikacji Flutter w wybranym katalogu.
- Uruchom Visual Studio Code.
- Otwórz paletę poleceń (
F1
,Ctrl+Shift+P
lubShift+Cmd+P
), a potem wpisz „flutter new”. Gdy się pojawi, wybierz polecenie Flutter: Nowy projekt.
- Wybierz Pusta aplikacja. Wybierz katalog, w którym utworzyć projekt. Może to być dowolny katalog, który nie wymaga rozszerzonych uprawnień ani nie zawiera spacji w ścieżce. Przykłady: katalog główny lub
C:\src\
.
- Nadaj projektowi nazwę
brick_breaker
. W dalszej części tego ćwiczenia zakładamy, że Twoja aplikacja ma nazwębrick_breaker
.
Flutter tworzy teraz folder projektu, a VS Code go otwiera. Teraz nadpisz zawartość 2 plików podstawowym szkieletem aplikacji.
Kopiowanie i wklejanie początkowej aplikacji
Spowoduje to dodanie do aplikacji przykładowego kodu udostępnionego w tym laboratorium kodu.
- W panelu po lewej stronie w VS Code kliknij Eksplorator 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.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
Plik pubspec.yaml
zawiera podstawowe informacje o aplikacji, takie jak jej obecna wersja, zależności i zasoby, z którymi zostanie dostarczona.
- 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. Powinien wyświetlić się nowe okno z czarnym tłem. Najgorsza gra wideo na świecie renderuje się teraz z prędkością 60 FPS!
4. Tworzenie gry
Rozmiar gry
Gra w 2 wymiarach (2D) wymaga obszaru gry. Najpierw utworzysz obszar o określonych wymiarach, a potem użyjesz tych wymiarów do określenia rozmiarów innych aspektów gry.
Istnieją różne sposoby rozmieszczania współrzędnych na obszarze gry. Według jednej konwencji kierunek można mierzyć od środka ekranu, a punkt początkowy (0,0)
znajduje się w środku ekranu. Wartości dodatnie przesuwają elementy w prawo wzdłuż osi x i w górę wzdłuż osi y. Ten standard dotyczy większości współczesnych gier, zwłaszcza tych, które wykorzystują 3 wymiary.
Podczas tworzenia oryginalnej gry Breakout przyjęto, że punkt początkowy znajduje się w lewym górnym rogu. Kierunek dodatni x pozostał bez zmian, ale oś y została odwrócona. Kierunek dodatni x był w prawo, a kierunek y w dół. W tym trybie gra ustawia punkt początkowy w lewym górnym rogu, aby zachować wierność historyczną.
Utwórz plik o nazwie config.dart
w nowym katalogu o nazwie lib/src
. W kolejnych krokach ten plik będzie zawierał więcej stałych.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Ta gra będzie mieć 820 pikseli szerokości i 1600 pikseli wysokości. Obszar gry jest dostosowywany do okna, w którym jest wyświetlany, ale wszystkie komponenty dodane do ekranu muszą mieć tę samą wysokość i szerokość.
Tworzenie obszaru gry
W grze Breakout piłka odbija się od ścian obszaru gry. Aby uwzględnić kolizje, musisz najpierw utworzyć komponent PlayArea
.
- Utwórz plik o nazwie
play_area.dart
w nowym katalogu o nazwielib/src/components
. - Dodaj do tego pliku te informacje.
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);
}
}
Flutter ma Widget
, a Flame – Component
. Aplikacje Flutter składają się z drzew widgetów, a gry Flame – z drzew komponentów.
To jest interesująca różnica między Flutterem a Flame. Drzewo widżetów Fluttera to tymczasowy opis, który służy do aktualizowania trwałej i zmiennej warstwy RenderObject
. Komponenty Flame są trwałe i zmienliwe, a oczekuje się, że deweloper będzie używać ich w ramach systemu symulacji.
Komponenty Flame są zoptymalizowane pod kątem mechaniki gry. Zaczniemy od pętli gry, która zostanie omówiona w następnym kroku.
- Aby uniknąć bałaganu, dodaj plik zawierający wszystkie komponenty w tym projekcie. Utwórz w folderze
lib/src/components
plikcomponents.dart
i dodaj do niego ten kod.
lib/src/components/components.dart
export 'play_area.dart';
Dyrektywa export
odgrywa odwrotną rolę niż dyrektywa import
. Określa on, jakie funkcje plik udostępnia po zaimportowaniu do innego pliku. Ten plik będzie się rozrastać wraz z dodawaniem nowych komponentów w kolejnych krokach.
Tworzenie gry Flame
Aby wyeliminować czerwone zawijasy z poprzedniego kroku, wyprowadz nową podklasę dla FlameGame
Flame.
- Utwórz w katalogu
lib/src
plik o nazwiebrick_breaker.dart
i dodaj do niego 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());
}
}
Plik ten koordynuje działania gry. Podczas tworzenia instancji gry kod ten konfiguruje grę tak, aby używała renderowania w ramach stałej rozdzielczości. Gra zmienia rozmiar, aby wypełnić ekran, na którym się znajduje, i w razie potrzeby dodaje letterbox.
Wyświetlasz szerokość i wysokość gry, aby komponenty podrzędne, takie jak PlayArea
, mogły ustawić odpowiedni rozmiar.
W przesłoniętej metodzie onLoad
kod wykonuje 2 działania.
- Konfiguruje lewy górny róg jako punkt zaczepienia wizjera. Domyślnie
viewfinder
używa środka obszaru jako punktu zakotwiczenia(0,0)
. - Dodaje
PlayArea
doworld
. Świat reprezentuje świat gry. Przekształca wszystkie swoje elementy za pomocą transformacji widokuCameraComponent
.
Uruchom grę na ekranie
Aby zobaczyć wszystkie zmiany wprowadzone na tym etapie, zaktualizuj plik lib/main.dart
, wprowadzając te 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ć tak jak na poniższym rysunku.
W kolejnym kroku dodasz do świata kulę i uruchomisz ją.
5. Wyświetlanie piłki
Tworzenie komponentu piłki
Umieszczenie na ekranie poruszającej się kuli wymaga utworzenia innego komponentu i dodania go do świata gry.
- W ten sposób zmodyfikuj zawartość pliku
lib/src/config.dart
.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
W tym samouczku wielokrotnie będziesz używać wzorca projektowania polegającego na definiowaniu stałych nazwanych jako wartości pochodne. Dzięki temu możesz zmodyfikować poziom najwyższy gameWidth
i gameHeight
, aby sprawdzić, jak zmieni się w efekcie wygląd i styl gry.
- Utwórz komponent
Ball
w pliku o nazwieball.dart
w folderzelib/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 zdefiniowano PlayArea
za pomocą RectangleComponent
, więc można założyć, że istnieje więcej kształtów. CircleComponent
, podobnie jak RectangleComponent
, pochodzi z PositionedComponent
, więc możesz ustawić piłkę na ekranie. Co ważniejsze, można zmienić jego pozycję.
Ten komponent przedstawia pojęcie velocity
, czyli zmianę pozycji w czasie. Prędkość jest obiektem Vector2
, ponieważ prędkość to zarówno prędkość, jak i kierunek. Aby zaktualizować pozycję, zastąp metodę update
, która jest wywoływana przez silnik gry w przypadku każdej klatki. dt
to czas między poprzednim a tym klatką. Dzięki temu możesz dostosować się do takich czynników jak różne częstotliwości klatek (60 Hz lub 120 Hz) czy długie klatki spowodowane nadmiernym przetwarzaniem.
Zwróć szczególną uwagę na aktualizację position += velocity * dt
. W ten sposób można zaimplementować aktualizowanie 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ść go w świecie i skonfiguruj, aby poruszał się po obszarze gry.
W pliku lib/src/brick_breaker.dart
zmodyfikuj w ten 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 powoduje dodanie do world
komponentu Ball
. Aby ustawić position
piłki na środku obszaru wyświetlania, kod najpierw zmniejsza rozmiar gry o połowę, ponieważ Vector2
ma przeciążenia operatora (*
i /
), aby skalować Vector2
według wartości skalarnej.
Ustawienie velocity
piłki jest bardziej skomplikowane. Celem jest przeniesienie piłki w losowym kierunku z rozsądną szybkością w dół ekranu. Wywołanie metody normalized
tworzy obiekt Vector2
ustawiony w tym samym kierunku co oryginalny obiekt Vector2
, ale z odległością 1. Dzięki temu prędkość piłki jest stała niezależnie od kierunku, w jakim się porusza. Prędkość piłki jest następnie zwiększana do 1/4 wysokości gry.
Aby uzyskać odpowiednie wartości, trzeba przeprowadzić kilka iteracji, czyli testów rozgrywki.
Ostatni wiersz włącza wyświetlanie informacji debugowania, które zawiera dodatkowe informacje ułatwiające debugowanie.
Gdy uruchomisz grę, powinna ona wyglądać tak:
Zarówno komponent PlayArea
, jak i komponent Ball
zawierają informacje debugujące, ale maty tła przycinają numery komponentu PlayArea
. Informacje debugowania są wyświetlane w przypadku wszystkich elementów, ponieważ włączono opcję debugMode
dla całego drzewa komponentów. Jeśli jest to dla Ciebie wygodniejsze, możesz też włączyć debugowanie tylko wybranych komponentów.
Jeśli uruchomisz grę kilka razy, możesz zauważyć, że piłka nie zachowuje się tak, jak powinna, gdy styka się ze ścianami. Aby uzyskać ten efekt, musisz dodać wykrywanie kolizji, co zrobisz w następnym kroku.
6. Odbijanie się
Dodaj wykrywanie kolizji
Wykrywanie kolizji dodaje zachowanie, w którym gra rozpoznaje, kiedy 2 obiekty wejdą ze sobą w kontakt.
Aby dodać do gry wykrywanie kolizji, dodaj do niej mixin HasCollisionDetection
, jak pokazano w tym kodzie.BrickBreaker
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;
}
}
Funkcja ta śledzi hitboxy komponentów i wywołuje wywołania zwrotne kolizji przy każdym kroku gry.
Aby zacząć wypełniać hitboxy gry, zmodyfikuj komponent PlayArea
, jak pokazano poniżej.
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 komponentu RectangleComponent
spowoduje utworzenie pola kolizji do wykrywania kolizji, które odpowiada rozmiarowi komponentu nadrzędnego. W przypadku RectangleHitbox
istnieje konstruktor fabryczny o nazwie relative
, który przydaje się, gdy chcesz utworzyć pole trafienia mniejsze lub większe niż komponent nadrzędny.
Odbijanie piłki
Dodanie wykrywania kolizji nie wpłynęło na rozgrywkę. Zmiana komponentu Ball
powoduje zmianę wartości. To zachowanie piłki musi się zmienić, gdy uderza w PlayArea
.
Zmień 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ę, dodając onCollisionStart
. System wykrywania kolizji dodany do BrickBreaker
w poprzednim przykładzie wywołuje tę funkcję zwracającą wartość.
Najpierw kod sprawdza, czy Ball
koliduje z PlayArea
. Na razie wydaje się to zbędne, ponieważ w świecie gry nie ma innych komponentów. Zmieni się to w następnym kroku, gdy dodasz nietoperza do świata. Następnie dodaje warunek else
, aby obsłużyć przypadek, gdy piłka zderza się z czymś innym niż kij. Przypomnienie o wprowadzeniu reszty logiki.
Gdy piłka uderza w dolną ścianę, znika z pola gry, choć nadal jest widoczna. Zajmiesz się tym artefaktem w kolejnych krokach, korzystając z efektów Flame.
Teraz, gdy piłka uderza w ściany, warto dać graczowi kij do uderzania w piłkę.
7. Uderz w piłkę
Tworzenie pałkowania
Aby dodać kij do gry, który pozwoli utrzymać piłkę w grze,
- W pliku
lib/src/config.dart
umieść kilka stałych wartości 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
są jasne i niewymagające wyjaśnień. Z drugiej strony stała batStep
wymaga wyjaśnienia. Aby w tej grze oddziaływać na piłkę, gracz może przeciągać kij myszą lub palcem (w zależności od platformy) albo użyć klawiatury. Stała batStep
określa, jak daleko przesuwa się nietoperz po naciśnięciu strzałki w lewo lub w prawo.
- W ten sposób zdefiniuj klasę komponentu
Bat
.
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 funkcji.
Po pierwsze, komponent Bat jest typem PositionComponent
, a nie RectangleComponent
ani CircleComponent
. Oznacza to, że kod musi renderować element Bat
na ekranie. Aby to osiągnąć, zastąpia wywołanie zwrotne render
.
Przyjrzyj się dokładnie wywołaniu canvas.drawRRect
(narysuj zaokrąglony prostokąt) i zastanów się, gdzie jest prostokąt. Funkcja Offset.zero & size.toSize()
korzysta z przeciążenia operator &
w klasie dart:ui
Offset
, która tworzy Rect
. Na początku może to wprowadzać w błąd, ale często spotkasz to w kodzie Fluttera i Flame na niższych poziomach.
Po drugie, ten komponent Bat
można przeciągać palcem lub myszką w zależności od platformy. Aby wdrożyć tę funkcję, dodaj mixin DragCallbacks
i zastąp zdarzenie onDragUpdate
.
Na koniec komponent Bat
musi reagować na polecenia klawiatury. Funkcja moveBy
umożliwia innemu kodowi polecenie przesunięcia nietoperza w lewo lub w prawo o określoną liczbę wirtualnych pikseli. Ta funkcja wprowadza nową możliwość silnika gry Flame: Effect
s. Dodanie obiektu MoveToEffect
jako elementu podrzędnego do tego komponentu powoduje, że gracz widzi latającą w nowej pozycji postać nietoperza. W Flame jest dostępna kolekcja Effect
, która umożliwia tworzenie różnych efektów.
Argumenty konstruktora efektu zawierają odwołanie do modułu game
getter. Dlatego w tej klasie uwzględniasz mixin HasGameReference
. Ten mixin dodaje do tego komponentu bezpieczny pod względem typu dostęp game
, aby uzyskać dostęp do instancji BrickBreaker
u szczytu drzewa komponentów.
- Aby udostępnić
Bat
aplikacjiBrickBreaker
, zaktualizuj pliklib/src/components/components.dart
w ten sposób:
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Dodanie nietoperza do świata
Aby dodać komponent Bat
do świata gry, 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( // 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.
}
Dodanie mixina KeyboardEvents
i przesłoniętej metody onKeyEvent
obsługuje dane wejściowe z klawiatury. Przypomnij sobie kod, który dodałeś wcześniej, aby przesunąć kij o odpowiednią wartość kroku.
Pozostała część dodanego kodu dodaje nietoperza do świata gry w odpowiednim miejscu i w odpowiednich proporcjach. Dzięki temu, że wszystkie te ustawienia są dostępne w tym pliku, łatwiej jest dostosować rozmiary pałki i piłki, aby uzyskać odpowiedni efekt w grze.
Jeśli w tym momencie zagrasz w grę, zauważysz, że możesz przesuwać kij, aby przechwycić piłkę, ale nie otrzymasz żadnej widocznej odpowiedzi poza logowaniem debugowania, które zostało zapisane w kodzie wykrywania kolizji w grze Ball
.
Czas to naprawić. W ten sposób możesz edytować komponent Ball
.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(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');
}
}
}
Te zmiany kodu rozwiązują 2 osobne problemy.
Po pierwsze, naprawia to problem znikania piłki w momencie, gdy dotyka dolnej części ekranu. Aby rozwiązać ten problem, zastąp wywołanie removeFromParent
wywołaniem RemoveEffect
. Funkcja RemoveEffect
usuwa piłkę ze świata gry, gdy piłka opuści widoczny obszar gry.
Po drugie, zmiany te poprawiają obsługę kolizji między kijem i piłka. Ten kod postępowania działa na korzyść gracza. Dopóki gracz dotyka piłki kijem, piłka wraca na górę ekranu. Jeśli uważasz, że pojazd jest zbyt łagodny, i chcesz uzyskać bardziej realistyczne zachowanie, zmień ustawienia sterowania, aby lepiej dopasować je do swoich preferencji.
Warto zwrócić uwagę na złożoność aktualizacji velocity
. Nie odwraca ona tylko składowej y
prędkości, jak to miało miejsce w przypadku kolizji ze ścianą. Aktualizuje ona też składnik x
w sposób zależny od względnego położenia pałki i piłki w momencie kontaktu. Daje to graczowi większą kontrolę nad tym, co robi piłka, ale nie przekazuje mu żadnych informacji o tym, jak dokładnie to działa.
Teraz, gdy masz kij, którym możesz uderzać piłkę, fajnie byłoby mieć cegły, które piłka mogłaby rozbijać.
8. Przełamanie bariery
Tworzenie cegiełek
Aby dodać klocki do gry:
- W pliku
lib/src/config.dart
umieść kilka stałych wartości 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>());
}
}
}
Większość tego kodu powinna być Ci już znajoma. Ten kod używa RectangleComponent
, który zawiera wykrywanie kolizji i bezpieczne pod względem typu odwołanie do gry BrickBreaker
u szczytu drzewa komponentów.
Najważniejszym nowym pojęciem wprowadzonym w tym kodzie jest sposób, w jaki gracz spełnia warunki zwycięstwa. Warunek zwycięstwa sprawdza, czy w świecie są klocki, i potwierdza, że pozostał tylko jeden. Może to być nieco mylące, ponieważ poprzedni wiersz usuwa ten element z jego jednostki nadrzędnej.
Najważniejsze jest to, że usunięcie komponentu to polecenie oczekujące. Usuwa on blokadę po wykonaniu tego kodu, ale przed następnym odświeżaniem świata gry.
Aby komponent Brick
był dostępny dla BrickBreaker
, zmodyfikuj 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';
Dodawanie klocków 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.
}
}
}
Wprowadza on jedyny nowy aspekt, mianowicie modyfikator trudności, który zwiększa prędkość piłki po każdej kolizji z cegłą. Ten parametr wymaga przetestowania w trakcie rozgrywki, aby znaleźć odpowiednią krzywą trudności dla Twojej gry.
W ten sposób możesz edytować grę BrickBreaker
.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
Jeśli uruchomisz grę w jej obecnej formie, zobaczysz wszystkie kluczowe elementy rozgrywki. Możesz wyłączyć debugowanie i uznać, że to wystarczy, ale coś wydaje się nie tak.
Może ekran powitalny, ekran z informacją o zakończeniu gry i wynik? Flutter może dodać te funkcje do gry, więc teraz musisz się tym zająć.
9. Wygraj grę
Dodawanie stanów gry
W tym kroku osadź grę Flame w opakowaniu Flutter, a potem dodaj nakładki Fluttera na ekrany powitalny, końcowy i zwycięski.
Najpierw zmodyfikuj pliki gry i komponentów, aby zaimplementować stan gry, który określa, czy ma być wyświetlany nakład, a jeśli tak, to który.
- 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 enumeracji playState
wymaga dużo pracy. Pokazuje, w którym momencie gracz rozpoczyna, gra i przegrywa lub wygrywa. Na początku pliku definiujesz enumerację, a potem tworzysz jej instancję jako ukryte stany z odpowiednimi metodami getter i setter. Te metody getter i setter umożliwiają modyfikowanie nakładek, gdy różne części gry wywołują przejścia między stanami rozgrywki.
Następnie dzielisz kod w onLoad
na metodę onLoad i nową metodę startGame
. Przed wprowadzeniem tej zmiany nową grę można było rozpocząć tylko przez ponowne uruchomienie gry. Dzięki tym dodatkom gracz może teraz rozpocząć nową grę bez konieczności podejmowania tak drastycznych środków.
Aby umożliwić graczowi rozpoczęcie nowej gry, skonfigurowano 2 nowe moduły obsługi. Dodałeś/dodałaś metodę obsługi dotyku i rozszerzyłeś/rozszerzyłaś metodę obsługi klawiatury, aby umożliwić użytkownikowi uruchomienie nowej gry w różnych trybach. W przypadku modelowania stanu gry warto zaktualizować komponenty, aby uruchamiały przejścia stanu gry, gdy gracz wygra lub przegra.
- Zmień 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 do funkcji RemoveEffect
wywołanie zwrotne onComplete
, które uruchamia stan odtwarzania gameOver
. Powinieneś uzyskać odpowiedni efekt, jeśli gracz pozwoli, aby piłka wypadła z dołu ekranu.
- W ten sposób możesz edytować komponent
Brick
:
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>());
}
}
}
Jeśli jednak uda mu się zniszczyć wszystkie cegły, zobaczy ekran „Wygrana”. Brawo, graczu!
Dodaj owijacz Flutter
Aby zapewnić miejsce na osadzenie gry i dodanie nakładek stanu gry, dodaj powłokę Flutter.
- Utwórz katalog
widgets
w katalogulib/src
. - Dodaj plik
game_app.dart
i wstaw do niego ten kod:
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,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
Większość zawartości tego pliku jest zgodna ze standardową budową drzewa widżetów Flutter. Elementy specyficzne dla Flame obejmują użycie funkcji GameWidget.controlled
do tworzenia i zarządzania instancją gry BrickBreaker
oraz nowego argumentu overlayBuilderMap
w funkcji GameWidget
.
Klucze tego overlayBuilderMap
muszą być zgodne z nakładkami dodanymi lub usuniętymi przez funkcję playState
w BrickBreaker
. Próba ustawienia nakładki, która nie znajduje się na tej mapie, powoduje niezadowolenie wszystkich.
- Aby wyświetlić tę nową funkcję na ekranie, zastąp plik
lib/main.dart
tym kodem.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Jeśli uruchomisz ten kod na iOS, Linuxie, Windowsie lub w przeglądarce, w grze wyświetli się oczekiwany wynik. Jeśli kierujesz reklamy na użytkowników systemu macOS lub Androida, musisz wprowadzić jeszcze jedną zmianę, aby umożliwić wyświetlanie google_fonts
.
Włączanie dostępu do czcionek
Dodawanie uprawnienia dot. Internetu na Androida
W przypadku Androida musisz dodać uprawnienie dostępu do internetu. W ten sposób możesz zmodyfikować element AndroidManifest.xml
:
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Edytowanie plików uprawnień w przypadku systemu macOS
W przypadku systemu macOS musisz edytować 2 pliki.
- Zmień plik
DebugProfile.entitlements
, aby odpowiadał poniższemu kodowi.
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>
- Zmień plik
Release.entitlements
, aby odpowiadał temu kodowi
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>
Uruchomienie tej wersji powinno spowodować wyświetlenie ekranu powitalnego oraz ekranu z informacją o zakończeniu lub wygraniu gry na wszystkich platformach. Te ekrany są może trochę zbyt proste i dobrze byłoby mieć wynik. Zgadnij, co będziesz robić w następnym kroku.
10. Zachowaj wynik
Dodawanie wyniku do gry
W tym kroku udostępnisz wynik gry otoczeniu kontekstu Flutter. W tym kroku udostępnisz stan gry Flame dookoła zarządzania stanem Flutter. Dzięki temu kod gry może aktualizować wynik za każdym razem, gdy gracz zniszczy cegłę.
- 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 score
do gry, powiązasz stan gry z zarządzaniem stanem w Flutterze.
- Zmodyfikuj klasę
Brick
, aby dodać punkt do wyniku, gdy gracz zniszczy cegły.
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 atrakcyjną wizualnie grę
Teraz, gdy możesz prowadzić w Flutterze statystyki, czas ułożyć widżety tak, aby dobrze wyglądały.
- Utwórz
score_card.dart
wlib/src/widgets
i dodaj te informacje.
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 nakładki będą wyglądać bardziej profesjonalnie. Wykorzystują one możliwości pakietu flutter_animate
, aby dodać do ekranów nakładek trochę ruchu i stylu.
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 laboratorium kodu Tworzenie interfejsów użytkownika nowej generacji w Flutterze.
Ten kod znacznie zmienił komponent GameApp
. Aby umożliwić ScoreCard
dostęp do score
, musisz przekształcić je z StatelessWidget
w StatefulWidget
. Dodanie podsumowania statystyk wymaga dodania elementu Column
, aby wyniki były widoczne nad grą.
Po drugie, aby ulepszyć ekran powitalny, zakończenia gry i wygranej, 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(
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.
),
),
),
),
),
);
}
}
Po wykonaniu tych czynności gra powinna działać na dowolnej z 6 docelowych platform Flutter. Gra powinna wyglądać tak:
11. Gratulacje
Gratulacje, udało Ci się utworzyć grę za pomocą Fluttera i Flame!
Użyto silnika 2D Flame do utworzenia gry i osadzono ją w opakowaniu Flutter. Użyłeś(-aś) efektów Flame do animowania i usuwania komponentów. Użyłeś pakietów Google Fonts i Flutter Animate, aby cała gra wyglądała estetycznie.
Co dalej?
Zapoznaj się z tymi ćwiczeniami z programowania…
- Tworzenie interfejsów nowej generacji w Flutterze
- Jak sprawić, aby aplikacja Flutter była ładna, a nie nudna
- Dodawanie zakupów w aplikacji do aplikacji Flutter