Omówienie ognia w technologii Flutter

1. Wprowadzenie

Flame to oparty na Flutterze silnik gier 2D. W tym laboratorium kodowania stworzysz grę inspirowaną jedną z klasycznych gier wideo z lat 70. – Breakout Steve'a Wozniaka. Do narysowania nietoperza, piłki i cegieł użyjesz komponentów Flame. Wykorzystasz efekty Flame, aby animować ruch nietoperza, i dowiesz się, jak zintegrować Flame z systemem zarządzania stanem Fluttera.

Po zakończeniu gra powinna wyglądać jak ten animowany GIF, choć nieco wolniej.

Nagranie ekranu z rozgrywki. Gra została znacznie przyspieszona.

Czego się nauczysz

  • Podstawy działania Flame, zaczynając od GameWidget.
  • Jak korzystać z pętli gry
  • Jak działają Component Flame. Są one podobne do widżetów Widget w Flutterze.
  • Jak radzić sobie z kolizjami.
  • Jak używać Effect do animowania Component.
  • Jak nakładać widżety Fluttera Widget na grę Flame.
  • Jak zintegrować Flame z zarządzaniem stanem w Flutterze.

Co utworzysz

W tym ćwiczeniu z programowania utworzysz grę 2D za pomocą Fluttera i Flame. Po zakończeniu gra powinna spełniać te wymagania:

  • działać na wszystkich 6 platformach obsługiwanych przez Fluttera: Android, iOS, Linux, macOS, Windows i internet;
  • Utrzymuj co najmniej 60 klatek na sekundę za pomocą pętli gry Flame.
  • Wykorzystaj możliwości Fluttera, takie jak pakiet google_fontsflutter_animate, aby odtworzyć klimat gier arkadowych z lat 80.

2. Konfigurowanie środowiska Flutter

Edytor

Aby uprościć to ćwiczenie, zakładamy, że Twoim środowiskiem programistycznym jest Visual Studio Code (VS Code). VS Code jest bezpłatny i działa na wszystkich głównych platformach. W tym ćwiczeniu z programowania używamy VS Code, ponieważ instrukcje domyślnie zawierają skróty specyficzne dla tego edytora. Zadania stają się prostsze: „kliknij ten przycisk” lub „naciśnij ten klawisz, aby wykonać czynność X”, a nie „wykonaj odpowiednią czynność w edytorze, aby wykonać czynność X”.

Możesz używać dowolnego edytora: Android Studio, innych środowisk IntelliJ IDE, Emacsa, Vima lub Notepad++. Wszystkie działają z Flutterem.

VS Code z kodem Flutter

Wybierz cel programowania

Flutter tworzy aplikacje na wiele platform. Aplikacja może działać w tych systemach operacyjnych:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • internet

Zwykle wybiera się jeden system operacyjny jako docelowy system operacyjny na potrzeby tworzenia aplikacji. Jest to system operacyjny, w którym aplikacja działa podczas programowania.

Rysunek przedstawiający laptopa i telefon podłączony do niego kablem. Laptop jest oznaczony jako

Przykład: załóżmy, że używasz laptopa z Windows do tworzenia aplikacji we Flutterze. Następnie wybierasz Androida jako platformę docelową. Aby wyświetlić podgląd aplikacji, podłącz urządzenie z Androidem do laptopa z Windows za pomocą kabla USB. Aplikacja w trakcie tworzenia zostanie uruchomiona na podłączonym urządzeniu z Androidem lub w emulatorze Androida. Możesz wybrać system Windows jako platformę docelową, co spowoduje uruchomienie aplikacji w trakcie tworzenia jako aplikacji na Windows obok edytora.

Zanim przejdziesz dalej, dokonaj wyboru. W każdej chwili możesz uruchomić aplikację w innych systemach operacyjnych. Wybór docelowego środowiska programistycznego ułatwi Ci wykonanie następnego kroku.

Instalowanie Flutera

Najnowsze instrukcje instalacji pakietu Flutter SDK znajdziesz na stronie docs.flutter.dev.

Instrukcje na stronie Fluttera obejmują instalację pakietu SDK, narzędzi związanych z platformą docelową i wtyczek do edytora. Aby ukończyć to ćwiczenie, zainstaluj te programy:

  1. Flutter SDK
  2. Visual Studio Code z wtyczką Fluttera
  3. Oprogramowanie kompilatora dla wybranego środowiska docelowego. (Aby kierować reklamy na Windowsa, musisz mieć Visual Studio, a aby kierować reklamy na macOS lub iOS – Xcode).

W następnej sekcji utworzysz pierwszy projekt Flutter.

Jeśli musisz rozwiązać jakieś problemy, pomocne mogą być te pytania i odpowiedzi (z StackOverflow).

Najczęstsze pytania

3. Utwórz projekt

Tworzenie pierwszego projektu Flutter

Obejmuje to otwarcie VS Code i utworzenie szablonu aplikacji Flutter w wybranym katalogu.

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

VS Code z

  1. Kliknij Empty Application (Pusta aplikacja). Wybierz katalog, w którym chcesz utworzyć projekt. Powinien to być dowolny katalog, który nie wymaga podwyższonych uprawnień ani nie zawiera spacji w ścieżce. Może to być na przykład katalog domowy lub C:\src\.

VS Code z wybraną opcją Pusta aplikacja w ramach nowego procesu tworzenia aplikacji

  1. Nadaj nazwę projektowibrick_breaker. W dalszej części tego ćwiczenia zakłada się, że aplikacja ma nazwę brick_breaker.

VS Code z

Flutter utworzy teraz folder projektu, a VS Code go otworzy. Teraz zastąpisz zawartość 2 plików podstawowym szkieletem aplikacji.

Kopiowanie i wklejanie aplikacji początkowej

Spowoduje to dodanie do aplikacji przykładowego kodu podanego w tym laboratorium.

  1. W lewym okienku VS Code kliknij Eksplorator i otwórz plik pubspec.yaml.

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

  1. Zastąp zawartość tego pliku tymi wierszami:

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 bieżąca wersja, zależności i zasoby, które będą z nią dostarczane.

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

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

  1. Zastąp zawartość tego pliku tymi wierszami:

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. Powinno się wyświetlić nowe okno z pustym czarnym tłem. Najgorsza gra na świecie renderowana jest teraz w 60 kl./s!

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

4. Tworzenie gry

Ocena gry

Gra dwuwymiarowa (2D) wymaga obszaru gry. Utworzysz obszar o określonych wymiarach, a następnie użyjesz tych wymiarów do określenia rozmiaru innych elementów gry.

W obszarze gry można rozmieszczać współrzędne na różne sposoby. Zgodnie z jedną z konwencji kierunek można mierzyć od środka ekranu, przy czym punkt początkowy (0,0)znajduje się na środku ekranu, a wartości dodatnie przesuwają elementy w prawo wzdłuż osi x i w górę wzdłuż osi y. Ten standard jest obecnie stosowany w większości gier, zwłaszcza tych trójwymiarowych.

W czasie tworzenia oryginalnej gry Breakout przyjęto konwencję, że punkt początkowy znajduje się w lewym górnym rogu. Kierunek dodatniej osi X pozostał bez zmian, ale oś Y została odwrócona. Kierunek osi X był dodatni w prawo, a osi Y w dół. Aby zachować klimat tamtych czasów, w tej grze punkt początkowy znajduje się w lewym górnym rogu.

Utwórz plik o nazwie config.dart w nowym katalogu o nazwie lib/src. W kolejnych krokach do tego pliku dodamy więcej stałych.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Ta gra będzie miała 820 pikseli szerokości i 1600 pikseli wysokości. Obszar gry jest skalowany tak, aby pasował do okna, w którym jest wyświetlany, ale wszystkie komponenty dodane do ekranu są dopasowane do tej wysokości i szerokości.

Tworzenie obszaru gry

W grze Breakout piłka odbija się od ścian obszaru gry. Aby uwzględnić kolizje, musisz najpierw mieć 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 wiersze:

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Tam, gdzie Flutter ma Widget, Flame ma Component. Aplikacje Flutter polegają na tworzeniu drzew widżetów, a gry Flame – na utrzymywaniu drzew komponentów.

Na tym polega ciekawa różnica między Flutterem a Flame. Drzewo widżetów Fluttera to ulotny opis, który służy do aktualizowania trwałej i zmiennej warstwy RenderObject. Komponenty Flame są trwałe i zmienne, a programista powinien używać ich w systemie symulacji.

Komponenty Flame są zoptymalizowane pod kątem wyrażania mechaniki gry. Ten przewodnik 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 plik components.dart w katalogu lib/src/components i dodaj do niego tę treść.

lib/src/components/components.dart

export 'play_area.dart';

Dyrektywa export pełni odwrotną rolę do dyrektywy import. Określa, jakie funkcje udostępnia ten plik po zaimportowaniu do innego pliku. W miarę dodawania nowych komponentów w kolejnych krokach ten plik będzie zawierać więcej wpisów.

Tworzenie gry w Flame

Aby usunąć czerwone zygzaki z poprzedniego kroku, utwórz nową podklasę dla FlameGame.

  1. Utwórz w katalogu lib/src plik o nazwie brick_breaker.dart i dodaj ten kod.

lib/src/brick_breaker.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

Ten plik koordynuje działania w grze. Podczas tworzenia instancji gry ten kod konfiguruje grę tak, aby używała renderowania o stałej rozdzielczości. Gra zmieni rozmiar, aby wypełnić ekran, na którym się znajduje, i w razie potrzeby doda letterboxing.

Udostępniasz szerokość i wysokość gry, aby komponenty podrzędne, takie jak PlayArea, mogły ustawić odpowiedni rozmiar.

onLoad zastąpionej metodzie kod wykonuje 2 działania.

  1. Konfiguruje lewy górny róg jako punkt zakotwiczenia wizjera. Domyślnie viewfinder używa środka obszaru jako punktu zakotwiczenia dla (0,0).
  2. Dodaje PlayArea do world. Świat reprezentuje świat gry. Wyświetla wszystkie elementy podrzędne za pomocą transformacji widoku CameraComponent.

Wyświetlanie gry na ekranie

Aby zobaczyć wszystkie zmiany wprowadzone na tym etapie, zaktualizuj plik lib/main.dart o 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ć jak na poniższym rysunku.

Zrzut ekranu przedstawiający okno aplikacji brick_breaker z prostokątem w kolorze piasku pośrodku.

W następnym kroku dodasz do świata piłkę i wprawisz ją w ruch.

5. Wyświetlanie piłki

Tworzenie komponentu piłki

Umieszczenie poruszającej się piłki na ekranie wymaga utworzenia kolejnego komponentu i dodania go do świata gry.

  1. Edytuj zawartość pliku lib/src/config.dart w ten sposób:

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

W tym laboratorium wielokrotnie pojawi się wzorzec projektowy definiowania nazwanych stałych jako wartości pochodnych. Dzięki temu możesz modyfikować wartości najwyższego poziomu gameWidthgameHeight, aby sprawdzić, jak zmienia się wygląd i styl gry.

  1. Utwórz komponent Ball w pliku o nazwie ball.dart w katalogu 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 logiczne jest, że istnieje więcej kształtów. CircleComponent, np. RectangleComponent, pochodzi z PositionedComponent, więc możesz umieścić piłkę na ekranie. Co ważniejsze, można zmienić jego pozycję.

Ten komponent wprowadza pojęcie velocity, czyli zmiany położenia w czasie. Prędkość jest obiektem Vector2, ponieważ określa zarówno szybkość, jak i kierunek. Aby zaktualizować pozycję, zastąp metodę update, którą silnik gry wywołuje dla każdej klatki. dt to czas między poprzednią a bieżącą klatką. Pozwala to dostosować się do takich czynników jak różne liczby klatek na sekundę (60 Hz lub 120 Hz) czy długie klatki spowodowane nadmierną liczbą obliczeń.

Zwróć szczególną uwagę na position += velocity * dt. W ten sposób możesz zaktualizować dyskretną symulację 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 tak, aby poruszał się po obszarze gry.

Zmodyfikuj plik lib/src/brick_breaker.dart 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 dodaje komponent Ball do world. Aby ustawić position piłki na środku obszaru wyświetlania, kod najpierw dzieli rozmiar gry na pół, ponieważ Vector2 ma przeciążenia operatorów (*/), które umożliwiają skalowanie Vector2 o wartość skalarną.

Ustawienie piłki velocity jest bardziej skomplikowane. Chodzi o to, aby piłka poruszała się w dół ekranu w losowym kierunku z rozsądną prędkością. Wywołanie metody normalized tworzy obiekt Vector2 ustawiony w tym samym kierunku co pierwotny obiekt Vector2, ale zmniejszony do odległości 1. Dzięki temu prędkość piłki jest stała niezależnie od kierunku, w którym się porusza. Prędkość piłki jest następnie skalowana do 1/4 wysokości gry.

Prawidłowe określenie tych różnych wartości wymaga pewnej liczby iteracji, które w branży są znane jako testowanie.

Ostatnia linia włącza wyświetlanie informacji na potrzeby debugowania, które dodaje do ekranu dodatkowe informacje ułatwiające debugowanie.

Po uruchomieniu gra powinna wyglądać mniej więcej tak:

Zrzut ekranu przedstawiający okno aplikacji brick_breaker z niebieskim okręgiem na prostokącie w kolorze piasku. Niebieskie kółko jest oznaczone liczbami wskazującymi jego rozmiar i położenie na ekranie.

Zarówno komponent PlayArea, jak i komponent Ball zawierają informacje do debugowania, ale maski tła przycinają liczby komponentu PlayArea. Wszystkie elementy zawierają informacje do debugowania, ponieważ włączono debugMode dla całego drzewa komponentów. Możesz też włączyć debugowanie tylko w przypadku wybranych komponentów, jeśli jest to bardziej przydatne.

Jeśli kilka razy zrestartujesz grę, możesz zauważyć, że piłka nie wchodzi w interakcję ze ścianami tak, jak można by się spodziewać. Aby uzyskać taki efekt, musisz dodać wykrywanie kolizji, co zrobisz w następnym kroku.

6. Odbijanie się

Dodawanie wykrywania kolizji

Wykrywanie kolizji to funkcja, dzięki której gra rozpoznaje, kiedy dwa obiekty weszły ze sobą w kontakt.

Aby dodać do gry wykrywanie kolizji, dodaj do BrickBreaker gry HasCollisionDetection mixin, jak pokazano w tym kodzie.

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

Śledzi to hitboxy komponentów i wywołuje funkcje zwrotne kolizji w każdym takcie gry.

Aby zacząć wypełniać obszary trafień w grze, zmodyfikuj komponent PlayArea w ten sposób:

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        children: [RectangleHitbox()],                          // Add this parameter
      );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Dodanie komponentu RectangleHitbox jako elementu podrzędnego komponentu RectangleComponent spowoduje utworzenie obszaru klikalnego do wykrywania kolizji, który będzie miał rozmiar komponentu nadrzędnego. Dla klasy RectangleHitbox istnieje konstruktor fabryczny o nazwie relative, który umożliwia utworzenie obszaru klikalnego mniejszego lub większego niż komponent nadrzędny.

Odbij piłkę

Dodanie wykrywania kolizji nie wpłynęło na rozgrywkę. Zmienia się po zmodyfikowaniu komponentu Ball. To zachowanie piłki musi się zmienić, gdy zderzy się ona z PlayArea.

Zmodyfikuj komponent Ball w ten sposób:

lib/src/components/ball.dart

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

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],                            // Add this parameter
       );

  final Vector2 velocity;

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

  @override                                                     // Add from here...
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

W tym przykładzie wprowadzono istotną zmianę, dodając wywołanie zwrotne onCollisionStart. System wykrywania kolizji dodany do BrickBreaker w poprzednim przykładzie wywołuje tę funkcję zwrotną.

Najpierw kod sprawdza, czy Ball zderzył się 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 do świata nietoperza. Następnie dodaje warunek else, który określa, co się stanie, gdy piłka zderzy się z czymś innym niż paletka. Delikatne przypomnienie o wdrożeniu pozostałej logiki.

Gdy piłka zderzy się z dolną ścianą, po prostu znika z pola gry, ale nadal jest dobrze widoczna. W kolejnym kroku wykorzystasz ten artefakt, korzystając z możliwości efektów Flame.

Teraz, gdy piłka zderza się ze ścianami gry, przydałoby się dać graczowi rakietkę, którą mógłby odbijać piłkę…

7. Uderzanie piłki kijem

Tworzenie nietoperza

Aby dodać paletkę, która będzie odbijać piłkę w grze:

  1. Wstaw do pliku lib/src/config.dart te stałe:

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 nie wymagają wyjaśnień. batStep stała wymaga jednak wyjaśnienia. Aby wejść w interakcję z piłką w tej grze, gracz może przeciągnąć kij myszą lub palcem (w zależności od platformy) albo użyć klawiatury. Stała batStep określa, jak daleko nietoperz przesuwa się po naciśnięciu klawisza strzałki w lewo lub w prawo.

  1. Zdefiniuj klasę komponentu Bat w ten sposób:

lib/src/components/bat.dart

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

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

Ten komponent wprowadza kilka nowych funkcji.

Po pierwsze, nietoperz jest PositionComponent, a nie RectangleComponent ani CircleComponent. Oznacza to, że ten kod musi renderować znak Bat na ekranie. W tym celu zastępuje wywołanie zwrotne render.

Przyjrzyj się bliżej wywołaniu canvas.drawRRect (narysuj zaokrąglony prostokąt) i zadaj sobie pytanie: „Gdzie jest prostokąt?”. Offset.zero & size.toSize() korzysta z przeciążenia operator & w klasie dart:ui Offset, która tworzy Rect. Ten skrót może Cię początkowo mylić, ale często pojawia się w kodzie Fluttera i Flame na niższych poziomach.

Po drugie, ten komponent Bat można przeciągać palcem lub myszą w zależności od platformy. Aby wdrożyć tę funkcję, dodaj miks DragCallbacks i zastąp zdarzenie onDragUpdate.

Na koniec komponent Bat musi reagować na sterowanie klawiaturą. Funkcja moveBy umożliwia innym fragmentom kodu 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. Dodając obiekt MoveToEffect jako element podrzędny tego komponentu, gracz widzi animację nietoperza w nowej pozycji. W programie Flame dostępna jest kolekcja Effect, która umożliwia tworzenie różnych efektów.

Argumenty konstruktora efektu zawierają odniesienie do funkcji pobierającej game. Dlatego w tej klasie umieszczasz mixin HasGameReference. Ten miks dodaje do komponentu bezpieczny pod względem typu akcesor game, który umożliwia dostęp do instancji BrickBreaker na górze drzewa komponentów.

  1. Aby udostępnić Bat usłudze 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';

Dodawanie 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 miksu KeyboardEvents i zastąpionej metody onKeyEvent obsługuje sygnały z klawiatury. Przypomnij sobie kod dodany wcześniej, aby przesunąć nietoperza o odpowiednią wartość kroku.

Pozostała część dodanego kodu umieszcza nietoperza w świecie gry w odpowiednim miejscu i z właściwymi proporcjami. Dzięki temu, że wszystkie te ustawienia są dostępne w tym pliku, możesz łatwo dostosować względny rozmiar kija i piłki, aby uzyskać odpowiednie wrażenia z gry.

Jeśli teraz zagrasz, zobaczysz, że możesz poruszać kijem, aby przechwycić piłkę, ale nie uzyskasz żadnej widocznej reakcji poza rejestrowaniem debugowania, które zostało w kodzie wykrywania kolizji Ball.

Czas to naprawić. Edytuj komponent Ball w ten sposób:

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;

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

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(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 w kodzie rozwiązują 2 osobne problemy.

Po pierwsze, zapobiega znikaniu piłki w momencie, gdy dotknie ona dolnej krawędzi ekranu. Aby rozwiązać ten problem, zastąp wywołanie removeFromParent wywołaniem RemoveEffect. RemoveEffect usuwa piłkę ze świata gry po tym, jak opuści ona widoczny obszar gry.

Po drugie, te zmiany poprawiają obsługę kolizji między kijem a piłką. Ten kod obsługi działa na korzyść gracza. Jeśli gracz dotknie piłki kijem, wróci ona na górę ekranu. Jeśli uważasz, że to zbyt pobłażliwe i chcesz czegoś bardziej realistycznego, zmień to ustawienie, aby lepiej dopasować je do tego, jak ma wyglądać Twoja gra.

Warto zwrócić uwagę na złożoność aktualizacji velocity. Nie tylko odwraca ona komponent y prędkości, jak w przypadku zderzeń ze ścianami. Aktualizuje też komponent x w sposób zależny od względnego położenia kija i piłki w momencie kontaktu. Daje to graczowi większą kontrolę nad tym, co robi piłka, ale nie jest to w żaden sposób komunikowane graczowi, z wyjątkiem rozgrywki.

Teraz, gdy masz już rakietkę do odbijania piłki, przydałoby się kilka cegieł, które można rozbić piłką.

8. Zburz mur

Tworzenie klocków

Aby dodać klocki do gry:

  1. Wstaw do pliku lib/src/config.dart te stałe:

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ć już znana. Ten kod używa RectangleComponent z wykrywaniem kolizji i bezpiecznym pod względem typów odwołaniem do gry BrickBreaker u góry drzewa komponentów.

Najważniejszą nową koncepcją wprowadzoną przez ten kod jest sposób, w jaki gracz osiąga warunek zwycięstwa. Sprawdzanie warunku zwycięstwa wysyła do świata zapytanie o klocki i potwierdza, że pozostał tylko jeden. Może to być nieco mylące, ponieważ poprzednia linia usuwa ten element z jego elementu nadrzędnego.

Kluczową kwestią jest to, że usunięcie komponentu to polecenie w kolejce. Usuwa ona klocek po uruchomieniu tego kodu, ale przed następnym taktem ś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 to jedyny nowy aspekt, czyli modyfikator trudności, który zwiększa prędkość piłki po każdym zderzeniu z cegłą. Ten parametr można dostosować. Aby znaleźć odpowiednią krzywą trudności, która będzie pasować do Twojej gry, musisz przeprowadzić testy.

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';

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ę, wyświetli ona wszystkie kluczowe mechanizmy rozgrywki. Możesz wyłączyć debugowanie i uznać, że to koniec, ale czegoś brakuje.

Zrzut ekranu przedstawiający grę brick_breaker z piłką, paletką i większością klocków na obszarze gry. Każdy z komponentów ma etykiety debugowania.

Co powiesz na ekran powitalny, ekran końca gry i może wynik? Flutter może dodać te funkcje do gry, na czym skupisz się w dalszej kolejności.

9. Wygraj grę

Dodawanie stanów odtwarzania

W tym kroku umieścisz grę Flame w otoczce Fluttera, a następnie dodasz nakładki Fluttera na ekrany powitalny, końca gry i wygranej.

Najpierw zmodyfikuj pliki gry i komponentów, aby wdrożyć stan gry, który odzwierciedla, czy wyświetlać nakładkę, a jeśli tak, to którą.

  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 wiele elementów gry BrickBreaker. Dodanie wyliczenia playState wymaga dużo pracy. Pokazuje, na jakim etapie jest gracz: czy dopiero zaczyna grę, czy już w nią gra, czy przegrywa, czy wygrywa. U góry pliku zdefiniuj wyliczenie, a następnie utwórz jego instancję jako ukryty stan z pasującymi metodami pobierającymi i ustawiającymi. Te funkcje pobierające i ustawiające umożliwiają modyfikowanie nakładek, gdy różne części gry wywołują przejścia stanu odtwarzania.

Następnie podziel kod w onLoad na funkcję onLoad i nową metodę startGame. Przed tą zmianą nową grę można było rozpocząć tylko przez ponowne uruchomienie gry. Dzięki tym nowym funkcjom gracz może teraz rozpocząć nową grę bez tak drastycznych środków.

Aby umożliwić graczowi rozpoczęcie nowej gry, skonfigurowano 2 nowe moduły obsługi gry. Dodano obsługę kliknięć i rozszerzono obsługę klawiatury, aby umożliwić użytkownikowi rozpoczęcie nowej gry w różnych trybach. Po modelowaniu stanu odtwarzania warto zaktualizować komponenty, aby wywoływać przejścia stanu odtwarzania, gdy gracz wygra lub przegra.

  1. Zmodyfikuj komponent Ball w ten sposób:

lib/src/components/ball.dart

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

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;

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

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            },
          ),
        );                                                      // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

Ta niewielka zmiana dodaje wywołanie zwrotne onComplete do RemoveEffect, które wywołuje stan odtwarzania gameOver. Powinno to być odpowiednie, jeśli gracz pozwoli piłce uciec z dolnej części ekranu.

  1. Edytuj komponent Brick w ten sposób:

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 graczowi uda się zbić wszystkie cegły, wyświetli się ekran „wygrana”. Brawo, brawo!

Dodawanie komponentu Flutter

Aby zapewnić miejsce na osadzenie gry i dodanie nakładek stanu gry, dodaj powłokę Fluttera.

  1. Utwórz katalog widgets w katalogu lib/src.
  2. Dodaj plik game_app.dart i wstaw do niego tę treść.

lib/src/widgets/game_app.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                          child: Text(
                            'TAP TO PLAY',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.gameOver.name: (context, game) => Center(
                          child: Text(
                            'G A M E   O V E R',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.won.name: (context, game) => Center(
                          child: Text(
                            'Y O U   W O N ! ! !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Większość treści w tym pliku jest zgodna ze standardową strukturą drzewa widżetów Fluttera. Części specyficzne dla Flame obejmują używanie GameWidget.controlled do tworzenia instancji gry BrickBreaker i zarządzania nią oraz nowego argumentu overlayBuilderMap w funkcji GameWidget.

Klucze tego overlayBuilderMap muszą być zgodne z nakładkami, które osoba ustawiająca playStateBrickBreaker dodała lub usunęła. Próba ustawienia nakładki, która nie znajduje się na tej mapie, spowoduje wyświetlenie niezadowolonych twarzy.

  1. Aby wyświetlić tę nową funkcję na ekranie, zastąp plik lib/main.dart tymi treściami.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

Jeśli uruchomisz ten kod w systemie iOS, Linux, Windows lub w internecie, zamierzony wynik wyświetli się w grze. Jeśli kierujesz reklamy na macOS lub Androida, musisz wprowadzić jeszcze jedną zmianę, aby wyświetlać symbol google_fonts.

Włączanie dostępu do czcionek

Dodawanie uprawnień do internetu na Androidzie

W przypadku Androida musisz dodać uprawnienie do internetu. Zmodyfikuj element AndroidManifest.xml w ten sposób:

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

Edytowanie plików uprawnień w systemie macOS

W przypadku systemu macOS musisz edytować 2 pliki.

  1. Zmodyfikuj plik DebugProfile.entitlements, aby pasował do tego kodu.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Edytuj plik Release.entitlements, aby pasował do tego kodu

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

Po uruchomieniu w takiej postaci na wszystkich platformach powinien się wyświetlić ekran powitalny oraz ekran zakończenia gry (wygranej lub przegranej). Te ekrany mogą być nieco uproszczone i warto byłoby mieć wynik. Zgadnij, co zrobisz w następnym kroku.

10. Prowadzenie punktacji

Dodawanie wyniku do gry

W tym kroku udostępnisz wynik gry w kontekście Fluttera. W tym kroku udostępnisz stan z gry Flame otaczającemu zarządzaniu stanem Fluttera. Dzięki temu kod gry może aktualizować wynik za każdym razem, gdy gracz rozbije 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, wiążesz stan gry z zarządzaniem stanem w Flutterze.

  1. Zmodyfikuj klasę Brick, aby dodawać punkt do wyniku, gdy gracz rozbije 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ą grę

Teraz, gdy możesz śledzić wyniki w Flutterze, czas połączyć widżety, aby gra wyglądała dobrze.

  1. Utwórz score_card.dartlib/src/widgets i dodaj te elementy:

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({super.key, required this.score});

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Utwórz plik overlay_screen.dart w folderze lib/src/widgets i dodaj ten kod.

Dzięki temu nakładki będą bardziej dopracowane, ponieważ pakiet flutter_animate umożliwia dodanie ruchu i stylu do ekranów nakładek.

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 samouczkiem Tworzenie interfejsów nowej generacji w Flutterze.

Ten kod uległ znacznym zmianom w komponencie GameApp. Najpierw, aby umożliwić ScoreCard dostęp do score , przekształć go z StatelessWidget na StatefulWidget. Dodanie podsumowania statystyk wymaga dodania elementu Column, aby umieścić wynik nad grą.

Po drugie, aby ulepszyć powitanie, zakończenie gry i wygraną, dodano 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 powinna być możliwość uruchomienia tej gry na dowolnej z 6 platform docelowych Fluttera. Gra powinna wyglądać mniej więcej tak:

Zrzut ekranu z grą brick_breaker przedstawiający ekran przed rozpoczęciem gry z prośbą do użytkownika o kliknięcie ekranu, aby rozpocząć grę

Zrzut ekranu z grą brick_breaker, na którym nałożony jest ekran z informacją o końcu gry, a pod nim widać paletkę i kilka klocków

11. Gratulacje

Gratulacje, udało Ci się stworzyć grę za pomocą Fluttera i Flame!

Gra została utworzona za pomocą silnika gier 2D Flame i osadzona w otoczce Fluttera. Użyto efektów Flame do animowania i usuwania komponentów. Używasz pakietów Google Fonts i Flutter Animate, aby cała gra wyglądała dobrze.

Co dalej?

Wypróbuj te ćwiczenia z programowania:

Więcej informacji