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

1. Введение

Flame — это двухмерный игровой движок на базе 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 кадров в секунду, используя игровой цикл 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
  • Андроид
  • Окна
  • macOS
  • Линукс
  • веб

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

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

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

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

Установить Флаттер

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

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

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

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

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

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

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

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

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

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

VS Code с

  1. Выберите Empty Application (Пустое приложение) . Выберите каталог для создания проекта. Это может быть любой каталог, не требующий повышенных привилегий и не содержащий пробелов в пути. Например, домашний каталог или 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 .

  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 , или изменения положения с течением времени. Velocity — это объект Vector2 , поскольку velocity одновременно является и скоростью, и направлением . Чтобы обновить положение, переопределите метод 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_Bracker с синим кругом поверх прямоугольника песочного цвета. Синий круг отмечен цифрами, указывающими его размер и местоположение на экране.

И у компонента 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 & в классе dart:ui Offset , создающем объекты 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);
    }
  }
}

Это небольшое изменение добавляет обратный вызов onComplete к RemoveEffect , который активирует состояние 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. На этом этапе вы предоставляете состояние игры Flame окружающему управлению состоянием 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. Создайте overlay_screen.dart в lib/src/widgets и добавьте следующий код.

Это добавит наложению больше блеска, используя возможности пакета 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),
        ],
      ),
    );
  }
}

Чтобы более подробно ознакомиться с возможностями flutter_animate , ознакомьтесь с лабораторной работой по созданию пользовательских интерфейсов следующего поколения во Flutter .

Этот код сильно изменился в компоненте GameApp . Во-первых, чтобы ScoreCard мог получить доступ к score , вы конвертируете его из StatelessWidget в StatefulWidget . Для добавления карточки счета требуется добавление Column , чтобы сложить счет над игрой.

Во-вторых, чтобы улучшить впечатления от приветствия, завершения игры и победы, вы добавили новый виджет OverlayScreen .

lib/src/widgets/game_app.dart

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

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

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

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

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

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

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

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

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

Скриншот Brick_breaker, показывающий игру поверх экрана, наложенного поверх биты и некоторых кирпичей.

11. Поздравления

Поздравляем, вам удалось создать игру с помощью Flutter и Flame!

Вы создали игру, используя игровой движок Flame 2D, и встроили ее в оболочку Flutter. Вы использовали эффекты Flame для анимации и удаления компонентов. Вы использовали пакеты Google Fonts и Flutter Animate, чтобы вся игра выглядела хорошо.

Что дальше?

Посмотрите некоторые из этих кодовых лабораторий...

Дальнейшее чтение