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

Что вы узнаете
- Как работают основные функции Flame, начиная с
GameWidget. - Как использовать игровой цикл.
- Как работают
ComponentFlame. Они похожи наWidgetFlutter. - Как обрабатывать столкновения.
- Как использовать
Effectдля анимацииComponent. - Как наложить
WidgetFlutter поверх игры Flame. - Как интегрировать Flame с системой управления состоянием Flutter.
Что вы построите
В этом практическом задании вы создадите 2D-игру с использованием Flutter и Flame. По завершении ваша игра должна соответствовать следующим требованиям:
- Flutter работает на всех шести платформах, которые он поддерживает: Android, iOS, Linux, macOS, Windows и веб.
- Поддерживайте частоту кадров не менее 60 fps, используя игровой цикл Flame.
- Используйте возможности Flutter, такие как пакет
google_fontsиflutter_animate, чтобы воссоздать атмосферу аркадных игр 80-х годов.
2. Настройте среду Flutter.
Редактор
Для упрощения данного практического занятия предполагается, что в качестве среды разработки используется Visual Studio Code (VS Code). VS Code бесплатен и работает на всех основных платформах. Мы используем VS Code для этого занятия, потому что в инструкциях по умолчанию используются сочетания клавиш, специфичные для VS Code. Задания становятся более понятными: «нажмите эту кнопку» или «нажмите эту клавишу, чтобы сделать X», а не «выполните соответствующее действие в редакторе, чтобы сделать X».
Вы можете использовать любой редактор на ваш выбор: Android Studio, другие IDE для IntelliJ, Emacs, Vim или Notepad++. Все они работают с Flutter.

Выберите целевую аудиторию разработки
Flutter позволяет создавать приложения для различных платформ. Ваше приложение может работать на любой из следующих операционных систем:
- iOS
- Android
- Windows
- macOS
- Linux
- веб
Обычно в качестве целевой операционной системы выбирают одну. Именно на этой операционной системе будет работать ваше приложение во время разработки.

Например: предположим, вы используете ноутбук с Windows для разработки своего Flutter-приложения. Затем вы выбираете Android в качестве целевой платформы разработки. Чтобы предварительно просмотреть приложение, вы подключаете устройство Android к своему ноутбуку с Windows с помощью USB-кабеля, и разрабатываемое приложение запускается на этом подключенном устройстве Android или в эмуляторе Android. Вы могли бы выбрать Windows в качестве целевой платформы разработки, что позволило бы запускать разрабатываемое приложение как приложение Windows параллельно с редактором.
Сделайте свой выбор, прежде чем продолжить. Вы всегда сможете запустить свое приложение на других операционных системах позже. Выбор целевой платформы разработки упростит следующий шаг.
Установите Flutter
Самые актуальные инструкции по установке Flutter SDK можно найти на сайте docs.flutter.dev .
Инструкции на веб-сайте Flutter описывают установку SDK, инструментов, связанных с целевой средой разработки, и плагинов редактора. Для выполнения этого практического задания установите следующее программное обеспечение:
- Flutter SDK
- Visual Studio Code с плагином Flutter
- Компилятор для выбранной вами целевой платформы разработки. (Для Windows требуется Visual Studio , а для macOS или iOS — Xcode ).
В следующем разделе вы создадите свой первый проект Flutter.
Если вам нужно устранить какие-либо неполадки, вам могут пригодиться некоторые из этих вопросов и ответов (со StackOverflow).
Часто задаваемые вопросы
- Как найти путь к Flutter SDK?
- Что делать, если команда Flutter не найдена?
- Как исправить проблему "Ожидание выполнения другой команды Flutter для снятия блокировки при запуске"?
- Как мне указать Flutter, где находится моя установленная версия Android SDK?
- Как мне справиться с ошибкой Java при выполнении команды
flutter doctor --android-licenses? - Как мне поступить, если инструмент
sdkmanagerв Android не найден? - Как мне справиться с ошибкой "отсутствует компонент
cmdline-tools"? - Как запустить CocoaPods на Apple Silicon (M1)?
- Как отключить автоматическое форматирование при сохранении в VS Code?
3. Создайте проект
Создайте свой первый проект Flutter
Для этого нужно открыть VS Code и создать шаблон приложения Flutter в выбранной вами директории.
- Запустите Visual Studio Code.
- Откройте палитру команд (
F1илиCtrl+Shift+PилиShift+Cmd+P), затем введите "flutter new". Когда появится меню, выберите команду "Flutter: Новый проект" .

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

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

Теперь Flutter создаст папку вашего проекта, и VS Code откроет её. Затем вы перезапишете содержимое двух файлов базовым шаблоном приложения.
Скопируйте и вставьте исходное приложение.
Это добавит в ваше приложение пример кода, предоставленный в этом руководстве.
- В левой панели VS Code щелкните «Проводник» и откройте файл
pubspec.yaml.

- Замените содержимое этого файла следующим:
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 содержит основную информацию о вашем приложении, такую как его текущая версия, зависимости и ресурсы, с которыми оно будет поставляться.
- Откройте файл
main.dartв каталогеlib/.

- Замените содержимое этого файла следующим:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Запустите этот код, чтобы убедиться, что всё работает. Должно отобразиться новое окно только с пустым чёрным фоном. Худшая в мире видеоигра теперь отображается со скоростью 60 кадров в секунду!

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 .
- Создайте файл с именем
play_area.dartв новой директории с именемlib/src/components. - Добавьте в этот файл следующее.
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 оптимизированы для выражения игровой механики. В этом практическом занятии мы начнем с игрового цикла, который будет представлен на следующем шаге.
- Чтобы избежать излишнего загромождения кода, добавьте файл, содержащий все компоненты этого проекта. Создайте файл
components.dartв папкеlib/src/componentsи добавьте в него следующее содержимое.
lib/src/components/components.dart
export 'play_area.dart';
Директива export выполняет обратную роль по отношению к import . Она определяет, какие функции этот файл предоставляет при импорте в другой файл. По мере добавления новых компонентов на следующих этапах, этот файл будет пополняться новыми записями.
Создайте игру Flame
Чтобы убрать красные закорючки с предыдущего шага, создайте новый подкласс для FlameGame из библиотеки Flame.
- Создайте файл с именем
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 ваш код выполняет два действия.
- Настраивает верхний левый угол в качестве точки привязки для видоискателя. По умолчанию
viewfinderиспользует середину области в качестве точки привязки для(0,0). - Добавляет
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));
}
После внесения этих изменений перезапустите игру. Игра должна выглядеть примерно так, как показано на рисунке ниже.

На следующем шаге вы добавите мяч в игровой мир и заставите его двигаться!
5. Выставьте мяч на показ.
Создайте компонент мяча.
Чтобы разместить движущийся шар на экране, необходимо создать еще один компонент и добавить его в игровой мир.
- Отредактируйте содержимое файла
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 верхнего уровня и изучать, как в результате меняется внешний вид и функциональность игры.
- Создайте компонент
Ballв файлеball.dartв папкеlib/src/components.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
Ранее вы определили PlayArea с помощью RectangleComponent , поэтому логично предположить, что существуют и другие фигуры. CircleComponent , как и RectangleComponent , наследуется от PositionedComponent , поэтому вы можете позиционировать мяч на экране. Что еще важнее, его положение можно обновлять.
Этот компонент знакомит с понятием velocity , или изменения положения во времени. Скорость — это объект Vector2 , поскольку скорость — это одновременно и скорость, и направление . Для обновления положения необходимо переопределить метод update , который игровой движок вызывает каждый кадр. dt — это длительность между предыдущим и текущим кадрами. Это позволяет адаптироваться к таким факторам, как разная частота кадров (60 Гц или 120 Гц) или длительные кадры из-за чрезмерных вычислений.
Обратите особое внимание на обновление по формуле position += velocity * dt . Так реализуется обновление дискретной симуляции движения во времени.
- Чтобы добавить компонент
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 высоты игрового поля.
Для точного определения этих различных значений требуется несколько итераций, также известных в индустрии как тестирование игры.
Последняя строка включает отладочный дисплей, который добавляет на экран дополнительную информацию, помогающую в отладке.
После запуска игры должно отобразиться следующее изображение.

Компоненты 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. Попадите битой по мячу.
Создайте летучую мышь
Чтобы добавить биту и сохранить мяч в игре,
- Вставьте следующие константы в файл
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 определяет, на какое расстояние бита делает шаг при каждом нажатии клавиши стрелки влево или вправо.
- Определите класс компонента
Batследующим образом.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
Этот компонент добавляет несколько новых возможностей.
Во-первых, компонент Bat является PositionComponent , а не RectangleComponent или CircleComponent . Это означает, что данный код должен отобразить Bat на экране. Для этого он переопределяет функцию обратного вызова render .
Внимательно рассмотрев вызов canvas.drawRRect (рисовать скругленный прямоугольник), вы можете задаться вопросом: «Где же сам прямоугольник?» Операторы Offset.zero & size.toSize() используют перегрузку operator & класса Offset из dart:ui которая создает Rect . Эта сокращенная запись может поначалу вас запутать, но вы будете часто встречать ее в низкоуровневом коде Flutter и Flame.
Во-вторых, этот компонент Bat можно перетаскивать как пальцем, так и мышью в зависимости от платформы. Для реализации этой функциональности необходимо добавить примесь DragCallbacks и переопределить событие onDragUpdate .
Наконец, компонент Bat должен реагировать на управление с клавиатуры. Функция moveBy позволяет другому коду указывать этой летучей мыши перемещаться влево или вправо на определенное количество виртуальных пикселей. Эта функция открывает новые возможности игрового движка Flame: Effect . Добавив объект MoveToEffect в качестве дочернего элемента этого компонента, игрок увидит анимацию перемещения летучей мыши в новое положение. В Flame доступен набор Effect для выполнения различных действий.
Аргументы конструктора класса Effect включают ссылку на геттер game . Именно поэтому необходимо добавить примесь HasGameReference к этому классу. Эта примесь добавляет типобезопасный аксессор game к этому компоненту для доступа к экземпляру BrickBreaker , находящемуся в верхней части дерева компонентов.
- Чтобы сделать
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. Снесите стену
Создайте кирпичики
Чтобы добавить кирпичики в игру,
- Вставьте следующие константы в файл
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.
- Вставьте компонент
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;
}
}
Если запустить игру, она отобразит все ключевые игровые механики. Можно отключить отладку и считать, что всё готово, но чего-то всё равно не хватает.

Как насчет приветственного экрана, экрана окончания игры и, возможно, счета? Flutter может добавить эти функции в игру, и именно на этом вы сосредоточите свое внимание в дальнейшем.
9. Выиграйте игру
Добавить игровые состояния
На этом этапе вы встраиваете игру Flame в оболочку Flutter, а затем добавляете наложения Flutter для экранов приветствия, окончания игры и победы.
Сначала вы изменяете файлы игры и компонентов, чтобы реализовать состояние воспроизведения, которое отражает, следует ли показывать наложение, и если да, то какое именно.
- Измените игру
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 . До этого изменения начать новую игру можно было только перезапустив её. Благодаря этим нововведениям игрок теперь может начать новую игру без таких радикальных мер.
Чтобы позволить игроку начать новую игру, вы настроили два новых обработчика для игры. Вы добавили обработчик касания и расширили обработчик клавиатуры, чтобы пользователь мог начать новую игру в нескольких режимах. После моделирования состояния игры было бы логично обновить компоненты, чтобы они запускали переходы между состояниями игры, когда игрок выигрывает или проигрывает.
- Измените компонент
Ballследующим образом.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(
RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
Это небольшое изменение добавляет в метод RemoveEffect функцию обратного вызова onComplete , которая запускает состояние игры gameOver . Это должно ощущаться правильно, если игрок позволит мячу вылететь за нижний край экрана.
- Отредактируйте компонент
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.
- Создайте каталог
widgetsв папкеlib/src. - Создайте файл
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 . Попытка установить наложение, которого нет на этой карте, приведет к негативной реакции со стороны пользователей.
- Чтобы отобразить эту новую функциональность на экране, замените файл
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 вам нужно отредактировать два файла.
- Отредактируйте файл
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>
- Отредактируйте файл
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. Это позволяет коду игры обновлять счет каждый раз, когда игрок разбивает кирпич.
- Измените игру
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.
- Измените класс
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 можно вести подсчет очков, пришло время собрать виджеты, чтобы придать ему привлекательный вид.
- Создайте
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!,
),
);
},
);
}
}
- Create
overlay_screen.dartinlib/src/widgetsand add the following code.
This adds more polish to the overlays using the power of the flutter_animate package to add some movement and style to the overlay screens.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
To get a more in-depth look at the power of flutter_animate , check out the Building next generation UIs in Flutter codelab.
This code changed a lot in the GameApp component. First, to enable ScoreCard to access the score , you convert it from a StatelessWidget to StatefulWidget . The addition of the score card requires the addition of a Column to stack the score above the game.
Second, to enhance the welcome, game over, and won experiences, you added the new OverlayScreen widget.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
With that all in place, you should now be able to run this game on any of the six Flutter target platforms. The game should resemble the following.
|
|
11. Поздравляем!
Congratulations, you succeeded in building a game with Flutter and Flame!
You built a game using the Flame 2D game engine and embedded it in a Flutter wrapper. You used Flame's Effects to animate and remove components. You used Google Fonts and Flutter Animate packages to make the whole game look well designed.
Что дальше?
Посмотрите некоторые из этих практических занятий по программированию...
- Building next generation UIs in Flutter
- Take your Flutter app from boring to beautiful
- Adding in-app purchases to your Flutter app

