Omówienie ognia w technologii Flutter

1. Wprowadzenie

Flame to oparty na technologii Flutter silnik gry 2D. Dzięki temu ćwiczeniu w Codelabs dowiesz się, jak stworzyć grę inspirowaną jedną z klasycznych gier wideo z lat 70. Steve'a Wozniaka „Breakout”. Użyjesz komponentów płomienia, aby narysować kij, piłkę i klocki. Wykorzystasz Flame's Effects, aby animować ruch nietoperza i zobaczyć, jak zintegrować Flame z systemem zarządzania stanem Flutter.

Po zakończeniu gra powinna wyglądać jak ten animowany GIF, ale będzie nieco wolniejsza.

Nagranie ekranu z uruchomionej gry. Gra została znacznie przyspieszona.

Czego się nauczysz

  • Jak działają podstawy Flame, zaczynając od GameWidget.
  • Jak korzystać z pętli gry
  • Jak działają Component Flame. Są podobne do Widget Fluttera.
  • Postępowanie w przypadku kolizji.
  • Jak używać elementów Effect do animowania elementów Component.
  • Jak nakładać Widget elementów Flutter na grę z płomieniem.
  • Jak zintegrować Flame z systemem zarządzania stanem w Flutter.

Co utworzysz

W ramach tego ćwiczenia w programie utworzysz grę 2D przy użyciu technologii Flutter i Flame. Gdy gra się zakończy, gra powinna spełniać te wymagania

  • funkcja na wszystkich 6 platformach obsługiwanych przez Flutter: Android, iOS, Linux, macOS, Windows i w przeglądarce.
  • Pętla gry Flame pozwala utrzymać prędkość co najmniej 60 klatek na sekundę.
  • Dzięki funkcjom Flutter, takim jak pakiet google_fonts i flutter_animate, możesz poczuć się jak w grach zręcznościowych z lat 80.

2. Konfigurowanie środowiska Flutter

Edytujący

Aby uprościć ten moduł, zakładamy, że Twoim środowiskiem programistycznym jest Visual Studio Code (VS Code). Aplikacja VS Code jest bezpłatna i działa na wszystkich głównych platformach. W tym ćwiczeniu z programowania używamy VS Code, ponieważ instrukcje domyślnie obejmują skróty VS Code. Zadania staną się prostsze: „kliknij ten przycisk”. lub „naciśnij ten klawisz, aby wykonać X” zamiast „wykonaj w edytorze odpowiednie działanie, aby wykonać X”.

Możesz używać dowolnego edytora: Android Studio, inne IDE IntelliJ, Emacs, Vim lub Notatnik++. Wszystkie współpracują z platformą Flutter.

Zrzut ekranu przedstawiający VS Code z kodem Flutter

Wybierz cel rozwojowy

Flutter tworzy aplikacje na wiele platform. Twoja aplikacja może działać w dowolnym z tych systemów operacyjnych:

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

Powszechną praktyką jest wybór jednego systemu operacyjnego na potrzeby programowania. Jest to system operacyjny, w którym Twoja aplikacja działa w trakcie tworzenia aplikacji.

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

Załóżmy na przykład, że tworzysz aplikację Flutter na laptopie z systemem Windows. Następnie jako cel programu musisz wybrać Androida. Aby zobaczyć podgląd aplikacji, podłącz urządzenie z Androidem do laptopa z systemem Windows przy użyciu kabla USB, a opracowana aplikacja działa na podłączonym urządzeniu z Androidem lub w emulatorze Androida. Jako cel rozwojowy możesz wybrać system Windows, który będzie uruchamiał Twoją aplikację jako aplikację Windows wraz z edytorem.

Może Cię kusić, że Twoim celem programowania może być internet. W trakcie tworzenia aplikacji ma to negatywny wpływ na działanie funkcji Stateful Hot Załaduj ponownie w usłudze Flutter. Obecnie Flutter nie może ponownie wczytywać aplikacji internetowych, jeśli

Wybierz, zanim przejdziesz dalej. Zawsze możesz uruchomić aplikację w innym systemie operacyjnym później. Ustalenie celu rozwoju sprawia, że kolejny krok przebiega sprawniej.

Zainstaluj Flutter

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

Instrukcje na stronie Flutter opisują instalację pakietu SDK, a także narzędzia związane z programowaniem i wtyczki edytora. Na potrzeby tego ćwiczenia w Codelabs zainstaluj następujące oprogramowanie:

  1. Pakiet SDK Flutter
  2. Kod w Visual Studio z wtyczką Flutter
  3. Skompiluj oprogramowanie na potrzeby wybranego środowiska programistycznego. (Musisz mieć Visual Studio, aby kierować reklamy na system Windows, lub Xcode, aby kierować reklamy na system macOS lub iOS).

W następnej sekcji utworzysz pierwszy projekt Flutter.

Niektóre z tych pytań i odpowiedzi (z StackOverflow) mogą okazać się pomocne w rozwiązywaniu problemów.

Najczęstsze pytania

3. Utwórz projekt

Tworzenie pierwszego projektu Flutter

Wymaga to otwarcia VS Code i utworzenia szablonu aplikacji Flutter w wybranym przez Ciebie katalogu.

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

Zrzut ekranu pokazujący VS Code

  1. Wybierz Empty Application (Pusta aplikacja). Wybierz katalog, w którym chcesz utworzyć projekt. Powinien to być dowolny katalog, który nie wymaga podwyższonych uprawnień ani nie ma spacji w ścieżce. Przykładem może być katalog główny lub adres C:\src\.

Zrzut ekranu pokazujący kod VS z pustą aplikacją wybraną jako część nowej aplikacji

  1. Nazwij projekt brick_breaker. W pozostałej części tego ćwiczenia w Codelabs zakładamy, że Twoja aplikacja nazywa się brick_breaker.

Zrzut ekranu pokazujący VS Code

Flutter utworzy teraz folder projektu, a VS Code otworzy go. Zastąpisz teraz zawartość dwóch plików podstawowym scaffrem aplikacji.

Kopiuj & Wklej początkową aplikację

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

  1. W lewym panelu VS Code kliknij Explorer i otwórz plik pubspec.yaml.

Częściowy zrzut ekranu VS Code ze strzałkami podkreślają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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

Plik pubspec.yaml zawiera podstawowe informacje o aplikacji, takie jak jej bieżąca wersja, zależności oraz zasoby, z którymi zostanie ona wysłana.

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

Częściowe zrzut ekranu aplikacji VS Code ze 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. Powinno wyświetlić się nowe okno z tylko pustym czarnym tłem. Największa na świecie gra wideo renderuje się teraz z szybkością 60 kl./s

Zrzut ekranu przedstawiający całkowicie czarne okno aplikacji brick_breaker.

4. Utwórz grę

Poznaj więcej możliwości

Rozgrywka w dwóch wymiarach (2D) wymaga obszaru zabaw. Zbudujesz obszar o konkretnych wymiarach, a potem użyj go do określenia wymiarów innych aspektów gry.

Współrzędne można rozmieścić na różne sposoby w obszarze gry. Zgodnie z jedną konwencją możesz mierzyć kierunek od środka ekranu, a punkt początkowy (0,0)znajduje się na środku ekranu. Wartości dodatnie powodują przenoszenie elementów w prawo wzdłuż osi X i w górę wzdłuż osi Y. Ten standard ma zastosowanie do większości współczesnych gier, zwłaszcza gdy są to gry złożone z 3 wymiarów.

Podczas tworzenia pierwotnej gry w podgrupach powołano się na ustawienie źródła w lewym górnym rogu. Dodatnia kierunek x pozostała taka sama, ale y została odwrócona. Kierunek osi X dodatniej był prawidłowy, a y był w dół. Aby wiernie odnosić się do epoki, początkiem tej gry jest lewy górny róg.

Utwórz plik o nazwie config.dart w nowym katalogu o nazwie lib/src. W kolejnych krokach plik ten uzyska więcej stałych wartości.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Ta gra będzie miała 820 pikseli szerokości i 1600 pikseli wysokości. Obszar gry dopasowuje się do okna, w którym jest wyświetlany, ale wszystkie elementy dodane do ekranu mają odpowiednią wysokość i szerokość.

Tworzenie obszaru PlayArea

W grze Breakout piłka odbija się od ścian strefy zabaw. Aby uwzględniać kolizje, potrzebujesz najpierw komponentu PlayArea.

  1. Utwórz plik o nazwie play_area.dart w nowym katalogu o nazwie lib/src/components.
  2. Dodaj do tego pliku te elementy.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

Tam, gdzie Flutter ma Widget, płomień ma Component. W aplikacjach Flutter można tworzyć drzewa widżetów, a gry Flame polegają na utrzymywaniu drzew złożonych z komponentów.

Istnieje ciekawa różnica między Flutterem a Płomieniem. Drzewo widżetów Flutter to opis tymczasowy, który służy do aktualizowania trwałej i zmiennej warstwy RenderObject. Komponenty Flame są trwałe i zmienne, a programista będzie ich używać w swoich systemach symulacji.

Komponenty Flame są zoptymalizowane pod kątem wyrażania mechaniki gry. To ćwiczenie w programowaniu rozpocznie się od pętli gry, którą przedstawimy w następnym kroku.

  1. Aby kontrolować bałagan, dodaj plik ze wszystkimi komponentami w tym projekcie. Utwórz plik components.dart w usłudze lib/src/components i dodaj do niego poniższą treść.

lib/src/components/components.dart

export 'play_area.dart';

Dyrektywa export odgrywa odwrotną rolę import. Deklaruje funkcje udostępniane przez ten plik po zaimportowaniu go do innego pliku. W miarę dodawania nowych komponentów w kolejnych krokach będziesz mieć więcej wpisów w tym pliku.

Stwórz grę „Płomień”

Aby zniwelować czerwone prążki z poprzedniego kroku, wyprowadź nową podklasę dla funkcji FlameGame Flame.

  1. Utwórz w pliku 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ę pod kątem renderowania o stałej rozdzielczości. Gra zmienia rozmiar, aby wypełnić ekran, na którym się znajduje, i w razie potrzeby dodaje listy letterbox.

Pokazujesz szerokość i wysokość gry, aby elementy podrzędne, np. PlayArea, mogły ustawić się w odpowiednim rozmiarze.

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

  1. Konfiguruje lewy górny róg jako element zakotwiczenia wizjera. Domyślnie wizjer wykorzystuje środek obszaru jako reklamę zakotwiczoną w przypadku elementu (0,0).
  2. Dodaje PlayArea do sekcji world. Świat reprezentuje świat gier. Wyświetla ona wszystkie swoje elementy podrzędne w ramach przekształcenia widoku CameraComponent.

Pokaż grę na ekranie

Aby zobaczyć wszystkie zmiany wprowadzone w tym kroku, dodaj do pliku lib/main.dart podane niżej zmiany.

lib/main.dart

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

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

Po wprowadzeniu tych zmian uruchom ponownie grę. Gra powinna wyglądać podobnie do ilustracji poniżej.

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

W następnym kroku dodasz do świata piłkę i zacznij się ruszać.

5. Wyświetl piłkę

Utwórz komponent kulki

Umieszczenie ruchomej piłki na ekranie wymaga utworzenia kolejnego komponentu i dodania go do świata gry.

  1. Zmodyfikuj zawartość pliku lib/src/config.dart w podany niżej sposób.

lib/src/config.dart

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

W tym ćwiczeniu w Codelabs wzorzec definiowania stałych nazwanych jako wartości pochodnych będzie zwracany wiele razy. Dzięki temu możesz zmodyfikować gameWidth i gameHeight najwyższego poziomu, aby sprawdzić, jak w wyniku tych zmian zmieni się wygląd i sposób działania gry.

  1. Utwórz komponent Ball w pliku o nazwie ball.dart w 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 kształt PlayArea został zdefiniowany przez Ciebie za pomocą elementu RectangleComponent, więc oznacza to, że istnieje więcej kształtów. CircleComponent, podobnie jak RectangleComponent, wywodzi się od PositionedComponent, więc możesz umieścić piłkę na ekranie. Co ważniejsze, jej pozycję można zmienić.

Ten komponent przedstawia pojęcie velocity, czyli zmiany pozycji w czasie. Prędkość to obiekt Vector2, ponieważ prędkość to zarówno prędkość, jak i kierunek. Aby zaktualizować położenie, zastąp metodę update, którą silnik gry wywołuje w przypadku każdej klatki. dt to czas między poprzednią a tą klatką. Dzięki temu możesz dostosować urządzenie do takich czynników jak różna liczba klatek (60 lub 120 Hz) lub długie klatki z powodu zbyt dużej mocy obliczeniowej.

Zwróć szczególną uwagę na aktualizację position += velocity * dt. W ten sposób wdrażasz aktualizację dyskretnej symulacji ruchu w czasie.

  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śćmy ją na całym świecie i skonfigurujmy, aby przesuwała się po placu zabaw.

Zmodyfikuj plik lib/src/brick_breaker.dart w następujący sposób.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math; // Add this import

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

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

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

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(                                             // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;                                           // To here.
  }
}

Ta zmiana dodaje komponent Ball do world. Aby ustawić position kuli w środku obszaru wyświetlania, kod najpierw zmniejsza rozmiar gry, ponieważ funkcja Vector2 zawiera przeciążenia operatorów (* i /) w celu skalowania Vector2 według wartości skalarnej.

Ustawienie velocity piłki wymaga bardziej złożonych działań. Celem jest przesuwanie piłki w losowym kierunku z rozsądną prędkością. Wywołanie metody normalized tworzy obiekt Vector2 ustawiony w tym samym kierunku co oryginalny obiekt Vector2, ale przeskalowany w dół do odległości 1. Dzięki temu prędkość piłki jest stała niezależnie od kierunku, w którym idzie. Prędkość piłki jest zwiększana do 1/4 wysokości gry.

Aby zapewnić prawidłowe działanie tych wartości, należy przeprowadzić kilka iteracji (tzw. „testy play” w branży).

Ostatni wiersz włącza ekran debugowania, który dodaje do niego dodatkowe informacje ułatwiające debugowanie.

Po uruchomieniu gry obraz powinien wyglądać podobnie do tego:

Zrzut ekranu przedstawiający okno aplikacji ceglaste z niebieskim okręgiem na tle prostokątnego w kolorze piasku. Niebieskie kółko jest oznaczone liczbami wskazującymi jego rozmiar i lokalizację na ekranie.

Zarówno komponent PlayArea, jak i Ball zawierają informacje debugowania, ale matowe tło obcina numery elementu PlayArea. Wszystkie informacje debugowania są wyświetlane, ponieważ włączono debugMode dla całego drzewa komponentów. Możesz też włączyć debugowanie tylko dla wybranych komponentów.

Po kilkukrotnym ponownym uruchomieniu gry możesz zauważyć, że piłka nie uderza w ściany tak, jak powinna. Aby uzyskać ten efekt, musisz dodać funkcję wykrywania kolizji, która zostanie wykonana w następnym kroku.

6. Odwróć się

Dodaj wykrywanie kolizji

Wykrywanie kolizji to funkcja, dzięki której gra rozpoznaje, kiedy 2 obiekty ze sobą stykają się.

Aby dodać do gry funkcję wykrywania kolizji, dodaj składankę HasCollisionDetection do gry BrickBreaker w sposób pokazany poniżej.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

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

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;
  }
}

Ta funkcja śledzi pola działań komponentów i uruchamia wywołania zwrotne w przypadku kolizji przy każdym kliknięciu w grze.

Aby zacząć wypełniać pola działań w grze, zmień komponent PlayArea w podany niżej sposób.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

Dodanie komponentu RectangleHitbox jako elementu podrzędnego względem RectangleComponent spowoduje utworzenie pola działania do wykrywania kolizji pasującego do rozmiaru komponentu nadrzędnego. Na potrzeby funkcji RectangleHitbox dostępny jest konstruktor fabryczny o nazwie relative, który przydaje się, gdy chcesz użyć pola trafienia, które jest mniejsze lub większe niż komponent nadrzędny.

Odrzuć piłkę

Jak dotąd dodanie funkcji wykrywania kolizji nie wpłynęło na rozgrywkę. Zmienia się po zmodyfikowaniu komponentu Ball. To zachowanie piłki musi się zmienić, gdy zderza ona z PlayArea.

Zmodyfikuj komponent Ball w ten sposób.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

W tym przykładzie wprowadzono dużą zmianę wraz z dodaniem wywołania zwrotnego onCollisionStart. System wykrywania kolizji dodany do funkcji BrickBreaker we wcześniejszym przykładzie wywołuje to wywołanie zwrotne.

Najpierw kod sprawdza, czy Ball zderzył się z PlayArea. Na razie wydaje się to niepotrzebne, ponieważ w świecie gry nie ma żadnych innych elementów. To zmieni się w następnym kroku, gdy dodasz do świata nietoperza. Następnie dodaje również warunek else, który ma być obsłużony, gdy piłka zderza się z obiektami, które nie są kijami. Przypominamy o przestrzeganiu wszystkich zasad.

Kiedy piłka zderza się z dolną ścianą, po prostu znika z powierzchni gry, wciąż pozostając w widoku. W przyszłości będziesz zajmować się tym artefaktem, wykorzystując moc Efektów Płomienia.

Teraz gdy piłka zderza się ze ścianami gry, dobrze byłoby dać graczowi kij, aby uderzyć piłkę...

7. Uderzenie piłką

Stwórz nietoperza

Aby dodać kij, który zapewni piłkę podczas gry,

  1. Wstaw stałe w pliku lib/src/config.dart w ten sposób.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

Stałe batHeight i batWidth nie wymagają wyjaśnień. Z kolei stała batStep wymaga wyjaśnienia. Aby wejść w interakcję z piłką w grze, gracz może przeciągnąć kij myszą lub palcem (w zależności od platformy) albo użyć klawiatury. Stała batStep określa, jak daleko odbijają się kroki każdego naciśnięcia klawisza strzałki w lewo lub w prawo.

  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 możliwości.

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

Jeśli spojrzysz z bliska w wywołanie funkcji canvas.drawRRect (zaokrąglony prostokąt), możesz zadać sobie pytanie: „Gdzie jest prostokąt?” Offset.zero & size.toSize() wykorzystuje przeciążenie operator & w klasie Offset dart:ui, która tworzy Rect. Ten skrót może być z początkiem zdezorientowany, ale w przypadku kodów Flutter i Flome na niższym poziomie będzie się pojawiać często.

Po drugie komponent Bat można przeciągać palcem lub myszą w zależności od platformy. Aby zaimplementować tę funkcję, musisz dodać składankę DragCallbacks i zastąpić zdarzenie onDragUpdate.

Ostatni: komponent Bat musi reagować na sterowanie za pomocą klawiatury. Funkcja moveBy umożliwia innemu kodowi polecenie przesuwania w lewo lub w prawo o określoną liczbę wirtualnych pikseli. Ta funkcja wprowadza nową możliwość silnika gry Flame: Effect. Gdy dodasz obiekt MoveToEffect jako element podrzędny tego komponentu, gracz będzie widzieć kij animowany w nowym położeniu. W Płomieniu dostępna jest kolekcja Effect utworów, dzięki którym możesz uzyskać różne efekty.

Argumenty konstruktora efektu zawierają odwołanie do metody pobierania game. Dlatego uwzględniasz składankę HasGameReference do tej klasy. Ta kombinacja dodaje do tego komponentu akcesor game zapewniający dostęp do instancji BrickBreaker na górze drzewa komponentów.

  1. Aby udostępnić Bat użytkownikowi BrickBreaker, zaktualizuj plik lib/src/components/components.dart w następujący sposób.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

Nie daj się wciągnąć w świat

Aby dodać komponent Bat do świata gier, zaktualizuj BrickBreaker w ten sposób.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

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

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here
}

Dodanie składanki KeyboardEvents i zastąpiona metoda onKeyEvent obsługuje wprowadzanie tekstu z klawiatury. Wycofaj dodany wcześniej kod, aby przesunąć kij o odpowiednią ilość kroków.

Pozostały fragment dodanego kodu dodaje kij do świata gry we właściwym położeniu i proporcjach. Ujawnienie wszystkich tych ustawień w tym pliku ułatwia dostosowanie względnego rozmiaru kija i piłki do stylu gry.

Jeśli zaczniesz grać w tym momencie, widzisz, że możesz poruszyć piłkę, by przechwycić piłkę, ale nie uzyskasz widocznej odpowiedzi poza zapisem debugowania zapisanym w kodzie wykrywania kolizji urządzenia Ball.

Teraz czas to naprawić. Zmodyfikuj komponent Ball w ten sposób.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(                                       // Modify from here...
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

Te zmiany w kodzie rozwiązują 2 osobne problemy.

Przede wszystkim eliminuje pojawianie się kulki w momencie dotknięcia dolnej części ekranu. Aby rozwiązać ten problem, zastąp połączenie typu removeFromParent elementem RemoveEffect. RemoveEffect usuwa piłkę ze świata gry po tym, jak pozwala jej opuścić widoczny obszar zabaw.

Po drugie, te zmiany służą do rozwiązywania problemów w przypadku kolizji między kijem a piłką. Taki kod obsługi działa bardzo na korzyść gracza. Gdy gracz dotknie piłki kijkiem, piłka wraca na górę ekranu. Jeśli wydaje Ci się to zbyt wybaczające i potrzebujesz czegoś bardziej realistycznego, zmień sposób obsługi tak, aby gra lepiej się pasowała.

Warto zwrócić uwagę na złożoność aktualizacji velocity. Odwraca ona nie tylko składnik y prędkości, jak miało to miejsce w przypadku zderzeń ścian. Aktualizuje też komponent x w sposób zależny od względnego położenia kija i piłki w momencie kontaktu. Dzięki temu gracz ma większą kontrolę nad tym, co robi piłka, ale nie przekazuje mu żadnych informacji poza przebiegiem gry.

Skoro masz już kij, którym możesz odbić piłkę, dobrze byłoby mieć klocki do złamania.

8. Zburz mur

Tworzenie klocków

Aby dodać klocki do gry:

  1. Wstaw stałe w pliku lib/src/config.dart w ten sposób.

lib/src/config.dart

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

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  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>());
    }
  }
}

Na razie większość kodu powinna być już znajoma. Ten kod wykorzystuje identyfikator RectangleComponent zawierający zarówno wykrywanie kolizji, jak i bezpieczne odniesienie do gry BrickBreaker na górze drzewa komponentów.

Najważniejszą nową koncepcją w tym kodzie jest sposób, w jaki gracz osiąga warunek wygranej. Kontrola warunku zwycięstwa wysyła zapytania do świata w poszukiwaniu klocków i potwierdza, że pozostanie tylko jedna z nich. Może to być nieco mylące, ponieważ poprzedni wiersz usuwa ten klocek z elementu nadrzędnego.

Pamiętaj, że usunięcie komponentu jest poleceniem w kolejce. Usuwa klocek po uruchomieniu kodu, ale przed kolejnym kliknięciem w świecie gry.

Aby udostępnić komponent Brick aplikacji BrickBreaker, edytuj lib/src/components/components.dart w ten sposób.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

Dodaj klocki do świata

Zaktualizuj komponent Ball w ten sposób.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

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

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

Jest to jedyny nowy aspekt – modyfikator trudności, który zwiększa prędkość piłki po każdym zderzeniu klocków. Ten parametr z możliwością regulacji trzeba przetestować, aby znaleźć odpowiednią krzywą poziomu trudności odpowiednią dla Twojej gry.

Edytuj grę BrickBreaker w ten sposób.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

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

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

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

Jeśli uruchomisz grę w obecnej wersji, zobaczysz wszystkie najważniejsze elementy mechaniki gry. Można wyłączyć debugowanie i wywołać to zadanie, ale wygląda na to, że coś brakuje.

Zrzut ekranu z rozbiciem klocków, na którym widać piłkę, kij i większość klocków na placu zabaw. Każdy z komponentów ma etykiety debugowania

Ekran powitalny, gra na ekranie i może wynik? Flutter może dodać te funkcje do gry i to właśnie na tym musisz się skupić.

9. Wygraj

Dodaj stany gry

W tym kroku umieszczasz grę Płomień wewnątrz otoki Flutter, a następnie dodajesz nakładki Flutter, które wyświetlają się na ekranach powitalnych, po zakończeniu gry i wygranych.

Najpierw musisz zmodyfikować pliki gry i komponentów, aby zaimplementować stan gry, który określa, czy ma być wyświetlana nakładka, a jeśli tak, to jaką.

  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 wyliczenia playState wymaga dużo pracy. Odzwierciedlają one moment, w którym gracz wchodzi do gry, gra, przegrywa lub wygrywa. Na początku pliku możesz zdefiniować wyliczenie, a następnie utworzyć je jako instancję ukrytą z pasującymi do niego pobierającymi i ustawiającymi. Te metody pobierania i seterów umożliwiają modyfikowanie nakładek, gdy różne elementy gry wywołują zmianę stanu gry.

Następnie podzieliłeś kod w onLoad na metodę onLoad i nową metodę startGame. Wcześniej można było rozpocząć nową grę tylko przez jej ponowne uruchomienie. Dzięki tym nowościom gracze mogą teraz rozpocząć nową grę bez tak drastycznych scen.

Aby umożliwić graczowi rozpoczęcie nowej gry, zostały skonfigurowane 2 nowe moduły obsługi. Został dodany moduł obsługi dotyku i rozszerzony moduł obsługi klawiatury, aby umożliwić użytkownikowi uruchamianie nowej gry w różnych modalnościach. Przy modelowaniu stanu gry warto zaktualizować komponenty tak, aby zmieniały stan gry, gdy gracz wygra lub przegra.

  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 aktywuje stan odtwarzania gameOver. Powinno to wyglądać dobrze, jeśli zawodnik pozwala piłce uciec spod ekranu.

  1. Zmodyfikuj komponent Brick w ten sposób.

lib/src/components/brick.dart

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

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

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Jeśli gracz zdobędzie wszystkie klocki, zwycięży w grze. ekranu. Świetna robota, gracz, dobra robota!

Dodaj opakowanie Flutter

Aby zapewnić miejsce na osadzenie gry i nakładki stanu gry, dodaj powłokę Flutter.

  1. Utwórz katalog widgets w domenie lib/src.
  2. Dodaj plik game_app.dart i wstaw do niego poniższą treść.

lib/src/widgets/game_app.dart

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

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

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

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

Większość treści w tym pliku jest tworzona zgodnie ze standardową kompilacją drzewa widżetów Flutter. Częścią charakterystyczną dla Flame jest użycie GameWidget.controlled do utworzenia instancji gry BrickBreaker i zarządzanie nią, a także nowego argumentu overlayBuilderMap w GameWidget.

Klucze tego elementu overlayBuilderMap muszą być zgodne z nakładkami dodanymi lub usuniętymi przez ustawienia playState w elemencie BrickBreaker. Próba ustawienia nakładki, której nie ma na tej mapie, powoduje wyświetlanie niezadowolonych twarzy dookoła.

  1. Aby ta nowa funkcja była widoczna na ekranie, zastąp plik lib/main.dart następującą treścią.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

Jeśli uruchomisz ten kod w systemie iOS, Linux, Windows lub na stronie internetowej, odpowiednie dane wyjściowe pojawią się w grze. Jeśli kierujesz swoją aplikację na system macOS lub Android, musisz wprowadzić ostatnie poprawki, aby włączyć wyświetlanie treści google_fonts.

Włączanie dostępu do czcionek

Dodawanie uprawnień do internetu na urządzeniu z Androidem

W przypadku Androida musisz dodać uprawnienia do korzystania z internetu. Edytuj AndroidManifest.xml w następujący sposób.

android/app/src/main/AndroidManifest.xml

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

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

Edytowanie plików uprawnień w systemie macOS

W systemie macOS musisz edytować 2 pliki.

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

macos/Runner/DebugProfile.entitlements

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

macos/Runner/Release.entitlements

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

Po uruchomieniu tej metody na wszystkich platformach powinien wyświetlać się ekran powitalny oraz ekran z informacją o rozgrywce lub wygranej. Te ekrany mogą być nieco uproszczone, ale dobrze byłoby poznać wynik. Odgadnij, co będziesz robić w następnym kroku.

10. Zachowaj wynik

Dodaj wynik do gry

W tym kroku przedstawisz wynik gry w kontekście technologii Flutter. W tym kroku ujawnisz stan z gry Flame do powiązanego zarządzania stanem Flutter. Dzięki temu kod gry aktualizuje wynik za każdym razem, gdy gracz złamuje klocek.

  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 do gry element score, musisz powiązać jej stan z zarządzaniem stanami Flutter.

  1. Zmień klasę Brick, by dodać punkt do wyniku, gdy gracz zbija klocki.

lib/src/components/brick.dart

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

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

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

Stwórz dobrze wyglądającą grę

Skoro masz już wynik w technologii Flutter, czas połączyć widżety, aby wyglądały dobrze.

  1. Utwórz score_card.dart w lib/src/widgets i dodaj następujące elementy.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

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

  final ValueNotifier<int> score;

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

Dzięki temu wygląd nakładek może być bardziej dopracowany dzięki możliwościom pakietu flutter_animate.

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({
    super.key,
    required this.title,
    required this.subtitle,
  });

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(
            subtitle,
            style: Theme.of(context).textTheme.headlineSmall,
          )
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

Aby dowiedzieć się więcej o możliwościach flutter_animate, zapoznaj się z ćwiczeniami z programowania dotyczącymi tworzenia interfejsów nowej generacji w Flutter.

Ten kod bardzo się zmienił w komponencie GameApp. Aby umożliwić usłudze ScoreCard dostęp do elementu score, musisz przekonwertować go z wartości StatelessWidget na StatefulWidget. Dodanie karty wyników wymaga dodania elementu Column, aby nałożyć wynik nad grą.

Po drugie, aby ulepszyć powitanie, grę i wygrane nagrody, dodaliśmy nowy widżet OverlayScreen.

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Teraz możesz zagrać w tę grę na dowolnej z 6 platform docelowych Flutter. Gra powinna wyglądać mniej więcej tak:

Zrzut ekranu przedstawiający klocków przed rozpoczęciem gry, w których użytkownik musi kliknąć ekran i zagrać w grę

Zrzuty ekranu przedstawiające grę w cegle

11. Gratulacje

Gratulacje, udało Ci się utworzyć grę w grze Flutter i Płomień.

Udało Ci się stworzyć grę za pomocą silnika gry Flame 2D i umieścić ją w opakowaniu Flutter. Udało Ci się wykorzystać efekt Flame's Effects do animowania i usuwania komponentów. Udało Ci się wykorzystać pakiety Google Fonts i Flutter Animate, dzięki czemu gra wygląda dobrze.

Co dalej?

Zapoznaj się z tymi ćwiczeniami z programowania...

Więcej informacji