Введение в Flame с Flutter

1. Введение

Flame — это игровой движок для 2D-игр на основе Flutter. В этом практическом занятии вы создадите игру, вдохновлённую одной из классических видеоигр 70-х годов — Breakout Стива Возняка. Вы будете использовать компоненты Flame для отрисовки биты, мяча и кирпичей. Вы будете использовать эффекты Flame для анимации движения биты и узнаете, как интегрировать Flame с системой управления состоянием Flutter.

После завершения разработки ваша игра должна выглядеть как этот анимированный GIF-файл, хотя и немного медленнее.

Запись экрана во время игры. Игра значительно ускорена.

Что вы узнаете

  • Как работают основные функции Flame, начиная с GameWidget .
  • Как использовать игровой цикл.
  • Как работают Component Flame. Они похожи на Widget Flutter.
  • Как обрабатывать столкновения.
  • Как использовать Effect для анимации Component .
  • Как наложить Widget Flutter поверх игры Flame.
  • Как интегрировать Flame с системой управления состоянием Flutter.

Что вы построите

В этом практическом задании вы создадите 2D-игру с использованием Flutter и Flame. По завершении ваша игра должна соответствовать следующим требованиям:

  • Flutter работает на всех шести платформах, которые он поддерживает: Android, iOS, Linux, macOS, Windows и веб.
  • Поддерживайте частоту кадров не менее 60 fps, используя игровой цикл Flame.
  • Используйте возможности Flutter, такие как пакет google_fonts и flutter_animate , чтобы воссоздать атмосферу аркадных игр 80-х годов.

2. Настройте среду Flutter.

Редактор

Для упрощения данного практического занятия предполагается, что в качестве среды разработки используется Visual Studio Code (VS Code). VS Code бесплатен и работает на всех основных платформах. Мы используем VS Code для этого занятия, потому что в инструкциях по умолчанию используются сочетания клавиш, специфичные для VS Code. Задания становятся более понятными: «нажмите эту кнопку» или «нажмите эту клавишу, чтобы сделать X», а не «выполните соответствующее действие в редакторе, чтобы сделать X».

Вы можете использовать любой редактор на ваш выбор: Android Studio, другие IDE для IntelliJ, Emacs, Vim или Notepad++. Все они работают с Flutter.

VS Code с фрагментом кода Flutter

Выберите целевую аудиторию разработки

Flutter позволяет создавать приложения для различных платформ. Ваше приложение может работать на любой из следующих операционных систем:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • веб

Обычно в качестве целевой операционной системы выбирают одну. Именно на этой операционной системе будет работать ваше приложение во время разработки.

Рисунок, изображающий ноутбук и телефон, соединенные с ноутбуком кабелем. Ноутбук обозначен как...

Например: предположим, вы используете ноутбук с Windows для разработки своего Flutter-приложения. Затем вы выбираете Android в качестве целевой платформы разработки. Чтобы предварительно просмотреть приложение, вы подключаете устройство Android к своему ноутбуку с Windows с помощью USB-кабеля, и разрабатываемое приложение запускается на этом подключенном устройстве Android или в эмуляторе Android. Вы могли бы выбрать Windows в качестве целевой платформы разработки, что позволило бы запускать разрабатываемое приложение как приложение Windows параллельно с редактором.

Сделайте свой выбор, прежде чем продолжить. Вы всегда сможете запустить свое приложение на других операционных системах позже. Выбор целевой платформы разработки упростит следующий шаг.

Установите Flutter

Самые актуальные инструкции по установке Flutter SDK можно найти на сайте docs.flutter.dev .

Инструкции на веб-сайте Flutter описывают установку SDK, инструментов, связанных с целевой средой разработки, и плагинов редактора. Для выполнения этого практического задания установите следующее программное обеспечение:

  1. Flutter SDK
  2. Visual Studio Code с плагином Flutter
  3. Компилятор для выбранной вами целевой платформы разработки. (Для Windows требуется Visual Studio , а для macOS или iOS — Xcode ).

В следующем разделе вы создадите свой первый проект Flutter.

Если вам нужно устранить какие-либо неполадки, вам могут пригодиться некоторые из этих вопросов и ответов (со StackOverflow).

Часто задаваемые вопросы

3. Создайте проект

Создайте свой первый проект Flutter

Для этого нужно открыть VS Code и создать шаблон приложения Flutter в выбранной вами директории.

  1. Запустите Visual Studio Code.
  2. Откройте палитру команд ( F1 или Ctrl+Shift+P или Shift+Cmd+P ), затем введите "flutter new". Когда появится меню, выберите команду "Flutter: Новый проект" .

VS Code с

  1. Выберите «Пустое приложение» . Выберите каталог для создания проекта. Это должен быть любой каталог, не требующий повышенных прав или не содержащий пробелов в пути. Примеры: ваш домашний каталог или C:\src\ .

В VS Code отображается пустое приложение, выбранное в рамках процесса создания нового приложения.

  1. Назовите свой проект brick_breaker . В оставшейся части этого практического задания предполагается, что вы назвали свое приложение brick_breaker .

VS Code с

Теперь Flutter создаст папку вашего проекта, и VS Code откроет её. Затем вы перезапишете содержимое двух файлов базовым шаблоном приложения.

Скопируйте и вставьте исходное приложение.

Это добавит в ваше приложение пример кода, предоставленный в этом руководстве.

  1. В левой панели VS Code щелкните «Проводник» и откройте файл pubspec.yaml .

Частичный снимок экрана VS Code со стрелками, указывающими на расположение файла pubspec.yaml.

  1. Замените содержимое этого файла следующим:

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

Файл pubspec.yaml содержит основную информацию о вашем приложении, такую ​​как его текущая версия, зависимости и ресурсы, с которыми оно будет поставляться.

  1. Откройте файл main.dart в каталоге lib/ .

Частичный снимок экрана VS Code со стрелкой, указывающей расположение файла main.dart.

  1. Замените содержимое этого файла следующим:

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. Запустите этот код, чтобы убедиться, что всё работает. Должно отобразиться новое окно только с пустым чёрным фоном. Худшая в мире видеоигра теперь отображается со скоростью 60 кадров в секунду!

Скриншот, на котором показано полностью чёрное окно приложения brick_breaker.

4. Создайте игру

Оцените игру

Для игры в двух измерениях (2D) необходимо игровое поле. Вы создадите поле определённых размеров, а затем используете эти размеры для определения размеров других элементов игры.

Существует несколько способов размещения координат на игровом поле. Согласно одной из конвенций, направление измеряется от центра экрана, при этом начало координат (0,0) находится в центре экрана; положительные значения перемещают объекты вправо по оси x и вверх по оси y. Этот стандарт применяется в большинстве современных игр, особенно в играх с трехмерным пространством.

В оригинальной игре Breakout начало координат было установлено в верхнем левом углу. Положительное направление оси X оставалось неизменным, однако ось Y была перевернута. Положительное направление оси X было вправо, а ось Y — вниз. Чтобы соответствовать духу той эпохи, в этой игре начало координат установлено в верхнем левом углу.

Создайте файл с именем config.dart в новой директории с именем lib/src . В последующих шагах в этот файл будут добавлены новые константы.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

Эта игра будет иметь ширину 820 пикселей и высоту 1600 пикселей. Игровое поле масштабируется под размер окна, в котором оно отображается, но все компоненты, добавляемые на экран, соответствуют этим высотам и ширине.

Создать игровую зону

В игре Breakout мяч отскакивает от стенок игровой зоны. Для учета столкновений сначала необходим компонент PlayArea .

  1. Создайте файл с именем play_area.dart в новой директории с именем lib/src/components .
  2. Добавьте в этот файл следующее.

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

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

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

В то время как Flutter использует Widget , Flame использует Component . Если приложения Flutter строятся из деревьев виджетов, то игры Flame строятся из деревьев компонентов.

В этом и заключается интересное различие между Flutter и Flame. Дерево виджетов Flutter представляет собой временное описание, предназначенное для обновления постоянного и изменяемого слоя RenderObject . Компоненты Flame являются постоянными и изменяемыми, и предполагается, что разработчик будет использовать эти компоненты в рамках системы моделирования.

Компоненты Flame оптимизированы для выражения игровой механики. В этом практическом занятии мы начнем с игрового цикла, который будет представлен на следующем шаге.

  1. Чтобы избежать излишнего загромождения кода, добавьте файл, содержащий все компоненты этого проекта. Создайте файл components.dart в папке lib/src/components и добавьте в него следующее содержимое.

lib/src/components/components.dart

export 'play_area.dart';

Директива export выполняет обратную роль по отношению к import . Она определяет, какие функции этот файл предоставляет при импорте в другой файл. По мере добавления новых компонентов на следующих этапах, этот файл будет пополняться новыми записями.

Создайте игру Flame

Чтобы убрать красные закорючки с предыдущего шага, создайте новый подкласс для FlameGame из библиотеки Flame.

  1. Создайте файл с именем brick_breaker.dart в lib/src и добавьте в него следующий код.

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

Этот файл координирует действия игры. Во время создания экземпляра игры этот код настраивает игру на рендеринг с фиксированным разрешением. Игра изменяет размер, чтобы заполнить экран, на котором она размещена, и добавляет черные полосы по мере необходимости.

Вы указываете ширину и высоту игры, чтобы дочерние компоненты, такие как PlayArea , могли самостоятельно устанавливать свои размеры.

В переопределенном методе onLoad ваш код выполняет два действия.

  1. Настраивает верхний левый угол в качестве точки привязки для видоискателя. По умолчанию viewfinder использует середину области в качестве точки привязки для (0,0) .
  2. Добавляет PlayArea в world . Мир представляет собой игровой мир. Он проецирует все свои дочерние элементы через преобразование представления компонента CameraComponent .

Вывести игру на экран

Чтобы увидеть все изменения, внесенные на этом шаге, обновите файл lib/main.dart , внеся следующие изменения.

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

После внесения этих изменений перезапустите игру. Игра должна выглядеть примерно так, как показано на рисунке ниже.

Скриншот окна приложения brick_breaker с прямоугольником песочного цвета посередине.

На следующем шаге вы добавите мяч в игровой мир и заставите его двигаться!

5. Выставьте мяч на показ.

Создайте компонент мяча.

Чтобы разместить движущийся шар на экране, необходимо создать еще один компонент и добавить его в игровой мир.

  1. Отредактируйте содержимое файла lib/src/config.dart следующим образом.

lib/src/config.dart

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

В этом практическом занятии будет многократно использоваться шаблон проектирования, при котором именованные константы определяются как производные значения. Это позволит вам изменять значения gameWidth и gameHeight верхнего уровня и изучать, как в результате меняется внешний вид и функциональность игры.

  1. Создайте компонент Ball в файле ball.dart в папке 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;
  }
}

Ранее вы определили PlayArea с помощью RectangleComponent , поэтому логично предположить, что существуют и другие фигуры. CircleComponent , как и RectangleComponent , наследуется от PositionedComponent , поэтому вы можете позиционировать мяч на экране. Что еще важнее, его положение можно обновлять.

Этот компонент знакомит с понятием velocity , или изменения положения во времени. Скорость — это объект Vector2 , поскольку скорость — это одновременно и скорость, и направление . Для обновления положения необходимо переопределить метод update , который игровой движок вызывает каждый кадр. dt — это длительность между предыдущим и текущим кадрами. Это позволяет адаптироваться к таким факторам, как разная частота кадров (60 Гц или 120 Гц) или длительные кадры из-за чрезмерных вычислений.

Обратите особое внимание на обновление по формуле position += velocity * dt . Так реализуется обновление дискретной симуляции движения во времени.

  1. Чтобы добавить компонент Ball в список компонентов, отредактируйте файл lib/src/components/components.dart следующим образом.

lib/src/components/components.dart

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

Добавьте мяч в мир

У вас есть мяч. Поместите его в игровой мир и настройте его на перемещение по игровой площадке.

Отредактируйте файл lib/src/brick_breaker.dart следующим образом.

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

Это изменение добавляет компонент Ball в игровой world . Чтобы установить position мяча в центре области отображения, код сначала уменьшает размер игры вдвое, поскольку Vector2 имеет перегрузки операторов ( * и / ) для масштабирования Vector2 на скалярное значение.

Задать velocity мяча — задача более сложная. Цель состоит в том, чтобы перемещать мяч вниз по экрану в случайном направлении с разумной скоростью. Вызов метода normalized создает объект Vector2 заданный в том же направлении, что и исходный Vector2 , но масштабированный до расстояния, равного 1. Это обеспечивает постоянную скорость мяча независимо от направления его движения. Затем скорость мяча масштабируется до 1/4 высоты игрового поля.

Для точного определения этих различных значений требуется несколько итераций, также известных в индустрии как тестирование игры.

Последняя строка включает отладочный дисплей, который добавляет на экран дополнительную информацию, помогающую в отладке.

После запуска игры должно отобразиться следующее изображение.

Скриншот окна приложения brick_breaker с синим кругом поверх прямоугольника песочного цвета. Синий круг снабжен цифрами, указывающими его размер и местоположение на экране.

Компоненты PlayArea и Ball содержат отладочную информацию, но фоновые маски обрезают цифры в PlayArea . Причина, по которой отображается отладочная информация для всех компонентов, заключается в том, что вы включили debugMode для всего дерева компонентов. Вы также можете включить отладку только для выбранных компонентов, если это более полезно.

Если вы несколько раз перезапустите игру, то можете заметить, что мяч взаимодействует со стенами не совсем так, как ожидалось. Для достижения этого эффекта необходимо добавить обнаружение столкновений, что вы сделаете на следующем шаге.

6. Попрыгайте.

Добавить обнаружение столкновений

Функция обнаружения столкновений добавляет в игру возможность распознавать момент соприкосновения двух объектов.

Чтобы добавить в игру обнаружение столкновений, добавьте примесь HasCollisionDetection к игре BrickBreaker , как показано в следующем коде.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

    debugMode = true;
  }
}

Эта функция отслеживает зоны поражения компонентов и запускает обратные вызовы обработки столкновений на каждом игровом такте.

Чтобы начать заполнять хитбоксы игры, измените компонент PlayArea , как показано ниже:

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

Добавление компонента RectangleHitbox в качестве дочернего элемента компонента RectangleComponent создаст область обнаружения столкновений, размер которой будет соответствовать размеру родительского компонента. Для компонента RectangleHitbox существует фабричный конструктор с именем relative , который используется, если вам нужна область обнаружения столкновений меньшего или большего размера, чем у родительского компонента.

Подбросьте мяч

Пока что добавление обнаружения столкновений никак не повлияло на игровой процесс. Изменения происходят после модификации компонента Ball . Изменяется именно поведение мяча при столкновении с PlayArea .

Измените компонент Ball следующим образом.

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

В этом примере внесено существенное изменение благодаря добавлению функции обратного вызова onCollisionStart . Система обнаружения столкновений, добавленная в BrickBreaker в предыдущем примере, вызывает эту функцию обратного вызова.

Сначала код проверяет, столкнулся ли Ball с PlayArea . На данный момент это кажется излишним, поскольку в игровом мире нет других компонентов. Это изменится на следующем шаге, когда вы добавите биту в мир. Затем добавляется условие else для обработки столкновений мяча с объектами, отличными от биты. Небольшое напоминание о необходимости реализовать оставшуюся логику.

Когда мяч сталкивается с нижней стенкой, он просто исчезает с игровой поверхности, оставаясь при этом в поле зрения. Вы обрабатываете этот артефакт на следующем этапе, используя возможности эффектов Пламени.

Теперь, когда мяч сталкивается со стенами игры, было бы очень полезно дать игроку биту, чтобы он мог отбивать мяч...

7. Попадите битой по мячу.

Создайте летучую мышь

Чтобы добавить биту и сохранить мяч в игре,

  1. Вставьте следующие константы в файл lib/src/config.dart .

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.

Константы batHeight и batWidth говорят сами за себя. Константа batStep , с другой стороны, требует небольшого пояснения. Для взаимодействия с мячом в этой игре игрок может перетаскивать биту мышью или пальцем, в зависимости от платформы, или использовать клавиатуру. Константа batStep определяет, на какое расстояние бита делает шаг при каждом нажатии клавиши стрелки влево или вправо.

  1. Определите класс компонента Bat следующим образом.

lib/src/components/bat.dart

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

import '../brick_breaker.dart';

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

  final Radius cornerRadius;

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

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

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

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

Этот компонент добавляет несколько новых возможностей.

Во-первых, компонент Bat является PositionComponent , а не RectangleComponent или CircleComponent . Это означает, что данный код должен отобразить Bat на экране. Для этого он переопределяет функцию обратного вызова render .

Внимательно рассмотрев вызов canvas.drawRRect (рисовать скругленный прямоугольник), вы можете задаться вопросом: «Где же сам прямоугольник?» Операторы Offset.zero & size.toSize() используют перегрузку operator & класса Offset из dart:ui которая создает Rect . Эта сокращенная запись может поначалу вас запутать, но вы будете часто встречать ее в низкоуровневом коде Flutter и Flame.

Во-вторых, этот компонент Bat можно перетаскивать как пальцем, так и мышью в зависимости от платформы. Для реализации этой функциональности необходимо добавить примесь DragCallbacks и переопределить событие onDragUpdate .

Наконец, компонент Bat должен реагировать на управление с клавиатуры. Функция moveBy позволяет другому коду указывать этой летучей мыши перемещаться влево или вправо на определенное количество виртуальных пикселей. Эта функция открывает новые возможности игрового движка Flame: Effect . Добавив объект MoveToEffect в качестве дочернего элемента этого компонента, игрок увидит анимацию перемещения летучей мыши в новое положение. В Flame доступен набор Effect для выполнения различных действий.

Аргументы конструктора класса Effect включают ссылку на геттер game . Именно поэтому необходимо добавить примесь HasGameReference к этому классу. Эта примесь добавляет типобезопасный аксессор game к этому компоненту для доступа к экземпляру BrickBreaker , находящемуся в верхней части дерева компонентов.

  1. Чтобы сделать Bat доступным для BrickBreaker , обновите файл lib/src/components/components.dart следующим образом.

lib/src/components/components.dart

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

Добавьте летучую мышь в мир

Чтобы добавить компонент Bat в игровой мир, обновите BrickBreaker следующим образом.

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

Добавление примеси KeyboardEvents и переопределенного метода onKeyEvent обрабатывает ввод с клавиатуры. Вспомните код, который вы добавили ранее для перемещения биты на соответствующее расстояние.

Оставшийся фрагмент добавленного кода добавляет биту в игровой мир в соответствующем положении и с правильными пропорциями. Наличие всех этих настроек в этом файле упрощает возможность корректировки относительного размера биты и мяча для достижения нужного ощущения от игры.

Если вы продолжите игру на этом этапе, вы увидите, что можете перемещать биту, чтобы перехватить мяч, но не получите никакого видимого ответа, кроме отладочной информации, которую вы оставили в коде обнаружения столкновений Ball .

Пора это исправить. Отредактируйте компонент Ball следующим образом.

lib/src/components/ball.dart

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

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

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

  final Vector2 velocity;

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

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

Эти изменения в коде устраняют две отдельные проблемы.

Во-первых, это исправляет проблему с исчезновением мяча в момент его касания нижней части экрана. Для решения этой проблемы необходимо заменить вызов removeFromParent на RemoveEffect . RemoveEffect удаляет мяч из игрового мира после того, как мяч покинет видимую игровую область.

Во-вторых, эти изменения исправляют обработку столкновения между битой и мячом. Этот код обработки работает в пользу игрока. Пока игрок касается мяча битой, мяч возвращается в верхнюю часть экрана. Если это кажется слишком мягким и вы хотите чего-то более реалистичного, измените обработку, чтобы она лучше соответствовала вашим представлениям об игре.

Стоит отметить сложность обновления velocity . Оно не просто меняет направление y -компоненты скорости, как это было сделано при столкновениях со стенами. Оно также обновляет x компоненту таким образом, что это зависит от относительного положения биты и мяча в момент контакта . Это дает игроку больший контроль над тем, что делает мяч, но точный механизм этого действия никак не сообщается игроку, кроме как в процессе игры.

Теперь, когда у вас есть бита, которой можно бить по мячу, было бы здорово иметь еще и кирпичи, чтобы разбивать их этим же мячом!

8. Снесите стену

Создайте кирпичики

Чтобы добавить кирпичики в игру,

  1. Вставьте следующие константы в файл lib/src/config.dart .

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. Вставьте компонент Brick следующим образом.

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

К этому моменту большая часть этого кода должна быть вам знакома. В этом коде используется компонент RectangleComponent , который включает в себя как обнаружение столкновений, так и типобезопасную ссылку на игру BrickBreaker в верхней части дерева компонентов.

Самая важная новая концепция, которую вводит этот код, — это способ достижения игроком условия победы. Проверка условия победы запрашивает у мира кирпичи и подтверждает, что остался только один. Это может немного сбивать с толку, поскольку предыдущая строка удаляет этот кирпич из родительского элемента.

Ключевой момент, который необходимо понять, заключается в том, что удаление компонента — это команда, выполняемая в очереди. Она удаляет кирпич после выполнения этого кода, но до следующего такта игрового мира.

Чтобы компонент Brick стал доступен для BrickBreaker , отредактируйте файл lib/src/components/components.dart следующим образом.

lib/src/components/components.dart

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

Добавьте кирпичики в мир

Обновите компонент Ball следующим образом.

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

Это вводит единственный новый аспект — модификатор сложности, который увеличивает скорость мяча после каждого столкновения с кирпичом. Этот настраиваемый параметр необходимо протестировать, чтобы найти подходящую кривую сложности для вашей игры.

Отредактируйте игру BrickBreaker следующим образом.

lib/src/brick_breaker.dart

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

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

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

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

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

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

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

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

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

    debugMode = true;
  }

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

Если запустить игру, она отобразит все ключевые игровые механики. Можно отключить отладку и считать, что всё готово, но чего-то всё равно не хватает.

Скриншот, демонстрирующий игровую механику brick_breaker с мячом, битой и большей частью кирпичей на игровом поле. Каждый компонент имеет метку отладки.

Как насчет приветственного экрана, экрана окончания игры и, возможно, счета? Flutter может добавить эти функции в игру, и именно на этом вы сосредоточите свое внимание в дальнейшем.

9. Выиграйте игру

Добавить игровые состояния

На этом этапе вы встраиваете игру Flame в оболочку Flutter, а затем добавляете наложения Flutter для экранов приветствия, окончания игры и победы.

Сначала вы изменяете файлы игры и компонентов, чтобы реализовать состояние воспроизведения, которое отражает, следует ли показывать наложение, и если да, то какое именно.

  1. Измените игру BrickBreaker следующим образом.

lib/src/brick_breaker.dart

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

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

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

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
}

Этот код существенно меняет игру BrickBreaker . Добавление перечисления playState требует значительных усилий. Оно фиксирует текущее состояние игрока на этапах входа, игры, а также проигрыша или выигрыша. В верхней части файла определяется перечисление, а затем оно инициализируется как скрытое состояние с соответствующими геттерами и сеттерами. Эти геттеры и сеттеры позволяют изменять наложения, когда различные части игры запускают переходы между состояниями игры.

Далее, код в onLoad разделяется на сам метод onLoad и новый метод startGame . До этого изменения начать новую игру можно было только перезапустив её. Благодаря этим нововведениям игрок теперь может начать новую игру без таких радикальных мер.

Чтобы позволить игроку начать новую игру, вы настроили два новых обработчика для игры. Вы добавили обработчик касания и расширили обработчик клавиатуры, чтобы пользователь мог начать новую игру в нескольких режимах. После моделирования состояния игры было бы логично обновить компоненты, чтобы они запускали переходы между состояниями игры, когда игрок выигрывает или проигрывает.

  1. Измените компонент Ball следующим образом.

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

Это небольшое изменение добавляет в метод RemoveEffect функцию обратного вызова onComplete , которая запускает состояние игры gameOver . Это должно ощущаться правильно, если игрок позволит мячу вылететь за нижний край экрана.

  1. Отредактируйте компонент Brick следующим образом.

lib/src/components/brick.dart

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

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

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

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

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

С другой стороны, если игроку удастся разбить все кирпичи, он получит экран «игра выиграна». Молодец, игрок, молодец!

Добавьте обертку Flutter.

Чтобы предоставить место для встраивания игры и добавления наложений состояния игры, добавьте оболочку Flutter.

  1. Создайте каталог widgets в папке lib/src .
  2. Создайте файл game_app.dart и вставьте в него следующее содержимое.

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

Большая часть содержимого этого файла соответствует стандартной структуре дерева виджетов Flutter. Части, специфичные для Flame, включают использование GameWidget.controlled для создания и управления экземпляром игры BrickBreaker , а также новый аргумент overlayBuilderMap для GameWidget .

Ключи этого overlayBuilderMap должны совпадать с наложениями, которые были добавлены или удалены с помощью метода playState в BrickBreaker . Попытка установить наложение, которого нет на этой карте, приведет к негативной реакции со стороны пользователей.

  1. Чтобы отобразить эту новую функциональность на экране, замените файл lib/main.dart следующим содержимым.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

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

Если вы запустите этот код на iOS, Linux, Windows или в веб-версии, желаемый результат отобразится в игре. Если вы ориентируетесь на macOS или Android, вам потребуется внести последнюю корректировку, чтобы включить отображение google_fonts .

Включить доступ к шрифтам

Добавить разрешение на доступ к интернету для Android

Для Android необходимо добавить разрешение на доступ к Интернету. Отредактируйте файл AndroidManifest.xml следующим образом.

android/app/src/main/AndroidManifest.xml

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

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

Редактирование файлов прав доступа для macOS

Для macOS вам нужно отредактировать два файла.

  1. Отредактируйте файл DebugProfile.entitlements , указав следующий код.

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. Отредактируйте файл Release.entitlements , указав следующий код.

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>

При запуске в текущем виде на всех платформах должны отображаться приветственный экран и экран окончания или победы игры. Эти экраны могут быть немного упрощенными, и было бы неплохо иметь отображение счета. Итак, угадайте, что вы будете делать на следующем шаге!

10. Ведите счет.

Добавить очки в игру

На этом этапе вы предоставляете игровой счет окружающему контексту Flutter. Это позволяет коду игры обновлять счет каждый раз, когда игрок разбивает кирпич.

  1. Измените игру BrickBreaker следующим образом.

lib/src/brick_breaker.dart

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

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

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

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

Добавляя score в игру, вы связываете состояние игры с системой управления состоянием Flutter.

  1. Измените класс Brick таким образом, чтобы к счету игрока добавлялось очко за каждый разбитый кирпич.

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

Создайте красивую игру.

Теперь, когда в Flutter можно вести подсчет очков, пришло время собрать виджеты, чтобы придать ему привлекательный вид.

  1. Создайте score_card.dart в lib/src/widgets и добавьте в него следующее.

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. Create overlay_screen.dart in lib/src/widgets and add the following code.

This adds more polish to the overlays using the power of the flutter_animate package to add some movement and style to the overlay screens.

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),
        ],
      ),
    );
  }
}

To get a more in-depth look at the power of flutter_animate , check out the Building next generation UIs in Flutter codelab.

This code changed a lot in the GameApp component. First, to enable ScoreCard to access the score , you convert it from a StatelessWidget to StatefulWidget . The addition of the score card requires the addition of a Column to stack the score above the game.

Second, to enhance the welcome, game over, and won experiences, you added the new OverlayScreen widget.

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

With that all in place, you should now be able to run this game on any of the six Flutter target platforms. The game should resemble the following.

A screenshot of brick_breaker showing the pre-game screen inviting the user to tap the screen to play the game

A screenshot of brick_breaker showing the game over screen overlaid on top of a bat and some of the bricks

11. Поздравляем!

Congratulations, you succeeded in building a game with Flutter and Flame!

You built a game using the Flame 2D game engine and embedded it in a Flutter wrapper. You used Flame's Effects to animate and remove components. You used Google Fonts and Flutter Animate packages to make the whole game look well designed.

Что дальше?

Посмотрите некоторые из этих практических занятий по программированию...

Дополнительная информация