Omówienie ognia w technologii Flutter

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.

Nagrywanie ekranu przedstawiające rozgrywkę. Gra została znacznie przyspieszona.

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 do Widget w Flutterze.
  • Jak obsługiwać kolizje.
  • Jak używać Effect do animowania Component.
  • 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_fontsflutter_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.

Zrzut ekranu z VS Code z kodem Fluttera

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.

Rysunek przedstawiający laptopa i telefon połączony z laptopem kablem. Laptop jest oznaczony jako

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:

  1. Flutter SDK
  2. Visual Studio Code z wtyczką Flutter
  3. 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

3. Utwórz projekt

Tworzenie pierwszego projektu Flutter

Polega to na otwarciu VS Code i utworzeniu szablonu aplikacji Flutter w wybranym katalogu.

  1. Uruchom Visual Studio Code.
  2. Otwórz paletę poleceń (F1, Ctrl+Shift+P lub Shift+Cmd+P), a potem wpisz „flutter new”. Gdy się pojawi, wybierz polecenie Flutter: Nowy projekt.

Zrzut ekranu z VS Code z

  1. 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\.

Zrzut ekranu z VS Code z pustą aplikacją wybraną w ramach nowego procesu tworzenia aplikacji

  1. Nadaj projektowi nazwę brick_breaker. W dalszej części tego ćwiczenia zakładamy, że Twoja aplikacja ma nazwę brick_breaker.

Zrzut ekranu z VS Code z

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.

  1. W panelu po lewej stronie w VS Code kliknij Eksplorator i otwórz plik pubspec.yaml.

Częściowy zrzut ekranu z VS Code z strzałkami wskazującymi lokalizację pliku pubspec.yaml

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

  1. Otwórz plik main.dart w katalogu lib/.

Częściowy zrzut ekranu z VS Code z strzałką wskazującą lokalizację pliku main.dart

  1. 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));
}
  1. 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!

Zrzut ekranu pokazujący okno aplikacji brick_breaker, które jest całkowicie czarne.

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.

  1. Utwórz plik o nazwie play_area.dart w nowym katalogu o nazwie lib/src/components.
  2. 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.

  1. Aby uniknąć bałaganu, dodaj plik zawierający wszystkie komponenty w tym projekcie. Utwórz w folderze lib/src/components plik components.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.

  1. Utwórz w katalogu lib/src plik o nazwie brick_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.

  1. Konfiguruje lewy górny róg jako punkt zaczepienia wizjera. Domyślnie viewfinder używa środka obszaru jako punktu zakotwiczenia (0,0).
  2. Dodaje PlayArea do world. Świat reprezentuje świat gry. Przekształca wszystkie swoje elementy za pomocą transformacji widoku CameraComponent.

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.

Zrzut ekranu przedstawiający okno aplikacji brick_breaker z piaskowym prostokątem w środku okna

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.

  1. 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 gameWidthgameHeight, aby sprawdzić, jak zmieni się w efekcie wygląd i styl gry.

  1. Utwórz komponent Ball w pliku o nazwie ball.dart w folderze lib/src/components.

lib/src/components/ball.dart

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

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

  final Vector2 velocity;

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

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.

  1. Aby uwzględnić komponent Ball na liście komponentów, zmodyfikuj plik lib/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 (*/), 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:

Zrzut ekranu pokazujący okno aplikacji brick_breaker z niebieskim okręgiem na wierzchu piaskowego prostokąta. Niebieski okrąg z numerami oznaczającymi jego rozmiar i położenie na ekranie

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,

  1. 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 batHeightbatWidth 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.

  1. 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: Effects. 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.

  1. Aby udostępnić Bat aplikacji BrickBreaker, zaktualizuj plik lib/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:

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

Zrzut ekranu pokazujący grę brick_breaker z kulą, kijem i większą częścią cegieł na polu gry. Każdy z tych komponentów ma etykiety debugowania.

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.

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

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

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

  1. Utwórz katalog widgets w katalogu lib/src.
  2. 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.

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

  1. 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>
  1. 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łę.

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

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

  1. Utwórz score_card.dart w lib/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!,
          ),
        );
      },
    );
  }
}
  1. Utwórz overlay_screen.dartlib/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:

Zrzut ekranu z aplikacji brick_breaker przedstawiający ekran zapraszający użytkownika do dotknięcia ekranu w celu rozpoczęcia gry

Zrzut ekranu z gry brick_breaker, na którym widać ekran z zakończeniem gry nałożony na kij i kilka cegieł

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…

Więcej informacji