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

1. Введение

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

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

Запись экрана происходящей игры. Игра значительно ускорена.

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

  • Как работают основы Flame, начиная с GameWidget .
  • Как использовать игровой цикл.
  • Как работают Component Flame. Они похожи на Widget Flutter.
  • Как справиться с столкновениями.
  • Как использовать Effect для анимации Component .
  • Как наложить Flutter Widget поверх игры 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 с сохранением состояния . Flutter в настоящее время не может выполнять горячую перезагрузку веб-приложений.

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

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

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

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

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

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

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

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

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

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

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

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

Скриншот 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.3.0 <4.0.0'

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

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

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

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

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

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

библиотека/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/компоненты/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/компоненты/comComponents.dart

export 'play_area.dart';

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

Создать игру «Пламя»

Чтобы погасить красные волнистые линии из предыдущего шага, создайте новый подкласс для 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. Настраивает верхний левый угол в качестве привязки для видоискателя. По умолчанию видоискатель использует середину области в качестве привязки для (0,0) .
  2. Добавляет PlayArea в world . Мир представляет игровой мир. Он проецирует всех своих дочерних элементов посредством преобразования представления CameraComponent .

Получить игру на экране

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

библиотека/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/компоненты/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 , поскольку скорость — это и скорость, и направление . Чтобы обновить позицию, переопределите метод update , который игровой движок вызывает для каждого кадра. dt — это продолжительность между предыдущим кадром и этим кадром. Это позволяет адаптироваться к таким факторам, как разная частота кадров (60 Гц или 120 Гц) или длинные кадры из-за чрезмерных вычислений.

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

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

lib/src/компоненты/comComponents.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.
  }
}

Это изменение добавляет в world компонент Ball . Чтобы установить 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/компоненты/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/компоненты/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/компоненты/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 s. Это сокращение может поначалу вас смутить, но вы часто будете видеть его в коде 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/компоненты/comComponents.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(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

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

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

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

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

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

lib/src/компоненты/ball.dart

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

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

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

  final Vector2 velocity;

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

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

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

Во-первых, исправлено исчезновение мяча в тот момент, когда он касается нижней части экрана. Чтобы устранить эту проблему, замените вызов 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/компоненты/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/компоненты/comComponents.dart

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

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

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

lib/src/компоненты/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/компоненты/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/компоненты/brick.dart

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

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

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

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

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

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

Добавьте обертку 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(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                              child: Text(
                                'TAP TO PLAY',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.gameOver.name: (context, game) => Center(
                              child: Text(
                                'G A M E   O V E R',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.won.name: (context, game) => Center(
                              child: Text(
                                'Y O U   W O N ! ! !',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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

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

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

библиотека/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/приложение/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/компоненты/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 , ознакомьтесь с UIS Next Generation в Codetab.

Этот код сильно изменился в компоненте 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(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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

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

Снимок экрана brick_breaker, показывающий игру над экраном на перекрытие поверх летучих мышей и некоторых кирпичей

11. Поздравляю

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

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

Что дальше?

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

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