Создайте 2D-игру с физикой с помощью Flutter и Flame.

Создайте 2D-игру с физикой с помощью Flutter и Flame

О практической работе

subjectПоследнее обновление: июн. 23, 2025
account_circleАвторы: Brett Morgan

1. Прежде чем начать

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

Анимация игрового процесса в этой 2D физической игре

Предпосылки

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

  • Основы работы Forge2D, начиная с различных типов физических тел.
  • Как настроить физическое моделирование в 2D.

Что вам нужно

Компилятор программного обеспечения для выбранной вами цели разработки. Эта кодовая лаборатория работает для всех шести платформ, которые поддерживает Flutter. Вам понадобится Visual Studio для Windows, Xcode для macOS или iOS и Android Studio для Android.

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

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

Существует много способов создания проекта Flutter. В этом разделе для краткости используется командная строка.

Для начала выполните следующие действия:

  1. В командной строке создайте проект Flutter:
    $ flutter create --empty forge2d_game
    Creating project forge2d_game...
    Resolving dependencies in forge2d_game... (4.7s)
    Got dependencies in forge2d_game.
    Wrote 128 files.
    
    All done!
    You can find general documentation for Flutter at: https://docs.flutter.dev/
    Detailed API documentation is available at: https://api.flutter.dev/
    If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev
    
    In order to run your empty application, type:
    
      $ cd forge2d_game
      $ flutter run
    
    Your empty application code is in forge2d_game/lib/main.dart.
    
  2. Измените зависимости проекта, добавив Flame и Forge2D:
    $ cd forge2d_game
    $ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
    Resolving dependencies...
    Downloading packages...
      characters 1.4.0 (from transitive dependency to direct dependency)
    + flame 1.29.0
    + flame_forge2d 0.19.0+2
    + flame_kenney_xml 0.1.1+12
      flutter_lints 5.0.0 (6.0.0 available)
    + forge2d 0.14.0
      leak_tracker 10.0.9 (11.0.1 available)
      leak_tracker_flutter_testing 3.0.9 (3.0.10 available)
      leak_tracker_testing 3.0.1 (3.0.2 available)
      lints 5.1.1 (6.0.0 available)
      material_color_utilities 0.11.1 (0.13.0 available)
      meta 1.16.0 (1.17.0 available)
    + ordered_set 8.0.0
    + petitparser 6.1.0 (7.0.0 available)
      test_api 0.7.4 (0.7.6 available)
      vector_math 2.1.4 (2.2.0 available)
      vm_service 15.0.0 (15.0.2 available)
    + xml 6.5.0 (6.6.0 available)
    Changed 8 dependencies!
    12 packages have newer versions incompatible with dependency constraints.
    Try `flutter pub outdated` for more information.
    

Пакет flame вам знаком, но остальные три могут потребовать некоторых пояснений. Пакет characters используется для манипуляции путями в соответствии с UTF8. Пакет flame_forge2d раскрывает функциональность Forge2D таким образом, который хорошо работает с Flame. Наконец, пакет xml используется в различных местах для потребления и изменения содержимого XML.

Откройте проект и замените содержимое файла lib/main.dart следующим:

lib/main.dart

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

void main() {
 
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}

Это запускает приложение с GameWidget , который создает экземпляр FlameGame . В этой кодовой лаборатории нет кода Flutter, который использует состояние экземпляра игры для отображения информации о запущенной игре, поэтому этот упрощенный bootstrap работает отлично.

Дополнительно: выполните побочный квест, доступный только для macOS.

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

Для этого выполните следующие действия:

  1. Создайте файл bin/modify_macos_config.dart и добавьте в него следующее содержимое:

bin/modify_macos_config.dart

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('macos/Runner/Base.lproj/MainMenu.xib');
 
var document = XmlDocument.parse(file.readAsStringSync());
 
document.xpath('//document/objects/window').first
   
..setAttribute('titlebarAppearsTransparent', 'YES')
   
..setAttribute('titleVisibility', 'hidden');
 
document
     
.xpath('//document/objects/window/windowStyleMask')
     
.first
     
.setAttribute('fullSizeContentView', 'YES');
 
file.writeAsStringSync(document.toString());
}

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

  1. Из базового каталога проекта запустите инструмент следующим образом:
dart bin/modify_macos_config.dart

Если все пойдет по плану, программа не выведет ничего в командной строке. Однако она изменит файл конфигурации macos/Runner/Base.lproj/MainMenu.xib чтобы запустить игру без видимой строки заголовка и с игрой Flame, занимающей все окно.

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

Окно приложения с черным фоном и пустым передним планом

3. Добавить изображения

Добавьте изображения

Любой игре нужны художественные ресурсы, чтобы иметь возможность рисовать экран таким образом, чтобы использовать find fun. Эта кодовая лаборатория будет использовать пакет Physics Assets от Kenney.nl . Эти ресурсы лицензированы Creative Commons CC0 , но я все равно настоятельно рекомендую сделать пожертвование команде Kenney, чтобы они могли продолжить свою замечательную работу. Я так и сделал.

Вам нужно будет изменить файл конфигурации pubspec.yaml , чтобы включить использование активов Кенни. Измените его следующим образом:

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  characters: ^1.4.0
  flame: ^1.29.0
  flame_forge2d: ^0.19.0+2
  flame_kenney_xml: ^0.1.1+12
  xml: ^6.5.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame ожидает, что ресурсы изображений будут располагаться в assets/images , хотя это можно настроить по-другому. Более подробную информацию см. в документации Flame Images . Теперь, когда пути настроены, вам нужно добавить их в сам проект. Один из способов сделать это — использовать командную строку следующим образом:

mkdir -p assets/images

Команда mkdir не должна выводить никаких данных, но новый каталог должен быть виден либо в вашем редакторе, либо в проводнике.

Разверните загруженный вами файл kenney_physics-assets.zip , и вы увидите что-то вроде этого:

Расширенный список файлов пакета kenney_physics-assets с выделенным каталогом PNG/Backgrounds

Из каталога PNG/Backgrounds скопируйте файлы colored_desert.png , colored_grass.png , colored_land.png и colored_shroom.png в каталог assets/images вашего проекта.

Существуют также листы спрайтов. Это комбинация изображения PNG и файла XML, который описывает, где в изображении листа спрайтов могут находиться более мелкие изображения. Листы спрайтов — это метод сокращения времени загрузки путем загрузки только одного файла, а не десятков, если не сотен, отдельных файлов изображений.

Развернутый список файлов пакета kenney_physics-assets с выделенным каталогом Spritesheet

Скопируйте spritesheet_aliens.png , spritesheet_elements.png и spritesheet_tiles.png в каталог assets/images вашего проекта. Пока вы здесь, также скопируйте файлы spritesheet_aliens.xml , spritesheet_elements.xml и spritesheet_tiles.xml в каталог assets вашего проекта. Ваш проект должен выглядеть следующим образом.

Список файлов каталога проекта forge2d_game с выделенным каталогом assets

Нарисуйте фон

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

Создайте файл с именем background.dart в новом каталоге с именем lib/components и добавьте следующее содержимое.

библиотека/компоненты/фон.dart

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

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
 
Background({required super.sprite})
   
: super(anchor: Anchor.center, position: Vector2(0, 0));

 
@override
 
void onMount() {
   
super.onMount();

   
size = Vector2.all(
     
max(
       
game.camera.visibleWorldRect.width,
       
game.camera.visibleWorldRect.height,
     
),
   
);
 
}
}

Этот компонент является специализированным SpriteComponent . Он отвечает за отображение одного из четырех фоновых изображений Kenney.nl. В этом коде есть несколько упрощающих предположений. Первое заключается в том, что изображения являются квадратными, как и все четыре фоновых изображения от Kenney. Второе заключается в том, что размер видимого мира никогда не изменится, в противном случае этому компоненту пришлось бы обрабатывать события изменения размера игры. Третье предположение заключается в том, что позиция (0,0) будет находиться в центре экрана. Эти предположения требуют особой конфигурации CameraComponent игры.

Создайте еще один новый файл, на этот раз с именем game.dart , снова в каталоге lib/components .

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));

   
return super.onLoad();
 
}
}

Здесь происходит много всего. Начните с класса MyPhysicsGame . В отличие от предыдущей кодовой лаборатории, это расширяет Forge2DGame , а не FlameGame . Сам Forge2DGame расширяет FlameGame несколькими интересными изменениями. Первое заключается в том, что по умолчанию zoom установлено на 10. Эта настройка zoom связана с диапазоном полезных значений, с которыми хорошо работают движки моделирования физики в стиле Box2D . Движок написан с использованием системы MKS, где предполагается, что единицы измерения — метры, килограммы и секунды. Диапазон, в котором вы не видите заметных математических ошибок для объектов, составляет от 0,1 метра до десятков метров. Подача размеров пикселей напрямую без некоторого уровня уменьшения масштаба выведет Forge2D за пределы его полезного диапазона. Полезное резюме заключается в том, чтобы представить себе моделирование объектов в диапазоне от банки с газировкой до автобуса.

Предположения, сделанные в компоненте Background, здесь удовлетворяются путем фиксации разрешения CameraComponent на 800 на 600 виртуальных пикселей. Это означает, что игровая область будет иметь ширину 80 единиц и высоту 60 единиц, центрированная в точке (0,0) . Это не влияет на отображаемое разрешение, но влияет на то, где мы размещаем объекты на игровой сцене.

Наряду с аргументом конструктора camera есть еще один, более физически ориентированный аргумент, называемый gravity . Гравитация установлена ​​на Vector2 с x = 0 и y = 10 . 10 является близким приближением к общепринятому значению 9,81 метра в секунду в секунду для гравитации. Тот факт, что гравитация установлена ​​на положительное значение 10, показывает, что в этой системе направление оси Y направлено вниз. Что в целом отличается от Box2D, но соответствует тому, как обычно настраивается Flame.

Далее следует метод onLoad . Этот метод асинхронный, что уместно, поскольку он отвечает за загрузку ресурсов изображений с диска. Вызовы images.load возвращают Future<Image> и в качестве побочного эффекта кэшируют загруженное изображение в объекте Game. Эти futures собираются вместе и ожидаются как единое целое с помощью статического метода Futures.wait . Затем список возвращенных изображений сопоставляется с шаблоном в отдельные имена.

Затем изображения spritesheet передаются в ряд объектов XmlSpriteSheet , которые отвечают за извлечение индивидуально именованных Sprites, содержащихся в spritesheet. Класс XmlSpriteSheet определен в пакете flame_kenney_xml .

После всего этого вам понадобится всего лишь внести несколько незначительных изменений в lib/main.dart чтобы получить изображение на экране.

lib/main.dart

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

import 'components/game.dart';                                    // Add this import

void main() {
 
runApp(GameWidget.controlled(gameFactory: MyPhysicsGame.new));  // Modify this line
}

С этим изменением вы теперь можете снова запустить игру, чтобы увидеть фон на экране. Обратите внимание, что экземпляр камеры CameraComponent.withFixedResolution() добавит letterboxing, как требуется, чтобы соотношение 800 на 600 в игре работало.

Приложение с зелеными холмами и странно абстрактными деревьями.

4. Добавьте землю.

Что-то, на чем можно строить

Если у нас есть гравитация, нам нужно что-то, чтобы ловить объекты в игре, прежде чем они упадут с нижней части экрана. Если, конечно, падение с экрана не является частью дизайна вашей игры. Создайте новый файл ground.dart в вашем каталоге lib/components и добавьте в него следующее:

lib/components/ground.dart

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

const groundSize = 7.0;

class Ground extends BodyComponent {
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Этот компонент Ground происходит от BodyComponent . В Forge2D тела важны, они являются объектами, которые являются частью двумерной физической симуляции. BodyDef для этого компонента указан как имеющий BodyType.static .

В Forge2D тела бывают трех разных типов. Статичные тела не двигаются. Они фактически имеют как нулевую массу — они не реагируют на гравитацию — так и бесконечную массу — они не двигаются при ударе о другие объекты, независимо от их веса. Это делает статические тела идеальными для поверхности земли, поскольку она не двигается.

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

Тело само по себе не делает многого. Телу нужны связанные формы, чтобы иметь вещество. В этом случае у этого тела есть одна связанная форма, PolygonShape , заданная как BoxXY . Этот тип коробки выровнен по оси с миром, в отличие от PolygonShape , заданного как BoxXY , который можно вращать вокруг точки вращения. Опять же полезно, но также выходит за рамки этой кодовой лаборатории. Форма и тело соединены вместе с помощью приспособления, которое полезно для добавления таких вещей, как friction в систему.

По умолчанию тело будет отображать присоединенные к нему формы способом, который полезен для отладки, но не подходит для отличного игрового процесса. Установка super renderBody в false отключает этот отладочный рендеринг. Предоставление этому телу внутриигрового рендеринга является обязанностью дочернего SpriteComponent .

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

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {                               // Add from here...
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}                                                        // To here.
}

Это изменение добавляет ряд компонентов Ground в мир с помощью цикла for внутри контекста List и передачи полученного списка компонентов Ground методу addAll world .

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

Окно приложения с фоном и земным слоем.

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

Построить стену

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

Кирпичи будут сделаны из таблицы спрайтов элементов. Если вы посмотрите на описание таблицы спрайтов в assets/spritesheet_elements.xml то увидите, что у нас есть интересная проблема. Имена, похоже, не очень полезны. Было бы полезно выбрать кирпич по типу материала, его размеру и количеству повреждений. К счастью, полезный эльф потратил некоторое время, чтобы выяснить шаблон в именовании файлов, и создал инструмент, чтобы облегчить вам задачу. Создайте новый файл generate_brick_file_names.dart в каталоге bin и добавьте следующее содержимое:

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
 
final file = File('assets/spritesheet_elements.xml');
 
final rects = <String, Rect>{};
 
final document = XmlDocument.parse(file.readAsStringSync());
 
for (final node in document.xpath('//TextureAtlas/SubTexture')) {
   
final name = node.getAttribute('name')!;
   
rects[name] = Rect(
     
x: int.parse(node.getAttribute('x')!),
     
y: int.parse(node.getAttribute('y')!),
     
width: int.parse(node.getAttribute('width')!),
     
height: int.parse(node.getAttribute('height')!),
   
);
 
}
 
print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
 
final int x;
 
final int y;
 
final int width;
 
final int height;
 
const Rect({
   
required this.x,
   
required this.y,
   
required this.width,
   
required this.height,
 
});

 
Size get size => Size(width, height);

 
@override
 
List<Object?> get props => [x, y, width, height];

 
@override
 
bool get stringify => true;
}

class Size extends Equatable {
 
final int width;
 
final int height;
 
const Size(this.width, this.height);

 
@override
 
List<Object?> get props => [width, height];

 
@override
 
bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
 
final groups = <Size, List<String>>{};
 
for (final entry in rects.entries) {
   
groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
 
}
 
final buff = StringBuffer();
 
buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
 
for (final entry in groups.entries) {
   
final size = entry.key;
   
final entries = entry.value;
   
entries.sort();
   
for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
     
var filtered = entries.where((element) => element.contains(type));
     
if (filtered.length == 5) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
     
} else if (filtered.length == 10) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
     
} else if (filtered.length == 15) {
       
buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
     
}
   
}
 
}
 
buff.writeln('''
  };
}''');
 
return buff.toString();
}

Ваш редактор должен выдать предупреждение или ошибку об отсутствующей зависимости. Добавьте ее с помощью следующей команды:

flutter pub add equatable

Теперь вы сможете запустить эту программу следующим образом:

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

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

Создайте файл brick.dart со следующим содержимым:

библиотека/компоненты/кирпич.дарт

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
 
explosive(density: 1, friction: 0.5),
 
glass(density: 0.5, friction: 0.2),
 
metal(density: 1, friction: 0.4),
 
stone(density: 2, friction: 1),
 
wood(density: 0.25, friction: 0.6);

 
final double density;
 
final double friction;

 
const BrickType({required this.density, required this.friction});
 
static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
 
size70x70(ui.Size(70, 70)),
 
size140x70(ui.Size(140, 70)),
 
size220x70(ui.Size(220, 70)),
 
size70x140(ui.Size(70, 140)),
 
size140x140(ui.Size(140, 140)),
 
size220x140(ui.Size(220, 140)),
 
size140x220(ui.Size(140, 220)),
 
size70x220(ui.Size(70, 220));

 
final ui.Size size;

 
const BrickSize(this.size);

 
static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
 
return switch ((type, size)) {
   
(BrickType.explosive, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementExplosive009.png',
     
BrickDamage.some: 'elementExplosive012.png',
     
BrickDamage.lots: 'elementExplosive050.png',
   
},
   
(BrickType.glass, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementGlass010.png',
     
BrickDamage.some: 'elementGlass013.png',
     
BrickDamage.lots: 'elementGlass048.png',
   
},
   
(BrickType.metal, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementMetal009.png',
     
BrickDamage.some: 'elementMetal012.png',
     
BrickDamage.lots: 'elementMetal050.png',
   
},
   
(BrickType.stone, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementStone009.png',
     
BrickDamage.some: 'elementStone012.png',
     
BrickDamage.lots: 'elementStone047.png',
   
},
   
(BrickType.wood, BrickSize.size140x70) => {
     
BrickDamage.none: 'elementWood011.png',
     
BrickDamage.some: 'elementWood014.png',
     
BrickDamage.lots: 'elementWood054.png',
   
},
   
(BrickType.explosive, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementExplosive011.png',
     
BrickDamage.some: 'elementExplosive014.png',
     
BrickDamage.lots: 'elementExplosive049.png',
   
},
   
(BrickType.glass, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementGlass011.png',
     
BrickDamage.some: 'elementGlass012.png',
     
BrickDamage.lots: 'elementGlass046.png',
   
},
   
(BrickType.metal, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementMetal011.png',
     
BrickDamage.some: 'elementMetal014.png',
     
BrickDamage.lots: 'elementMetal049.png',
   
},
   
(BrickType.stone, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementStone011.png',
     
BrickDamage.some: 'elementStone014.png',
     
BrickDamage.lots: 'elementStone046.png',
   
},
   
(BrickType.wood, BrickSize.size70x70) => {
     
BrickDamage.none: 'elementWood010.png',
     
BrickDamage.some: 'elementWood013.png',
     
BrickDamage.lots: 'elementWood045.png',
   
},
   
(BrickType.explosive, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementExplosive013.png',
     
BrickDamage.some: 'elementExplosive016.png',
     
BrickDamage.lots: 'elementExplosive051.png',
   
},
   
(BrickType.glass, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementGlass014.png',
     
BrickDamage.some: 'elementGlass017.png',
     
BrickDamage.lots: 'elementGlass049.png',
   
},
   
(BrickType.metal, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementMetal013.png',
     
BrickDamage.some: 'elementMetal016.png',
     
BrickDamage.lots: 'elementMetal051.png',
   
},
   
(BrickType.stone, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementStone013.png',
     
BrickDamage.some: 'elementStone016.png',
     
BrickDamage.lots: 'elementStone048.png',
   
},
   
(BrickType.wood, BrickSize.size220x70) => {
     
BrickDamage.none: 'elementWood012.png',
     
BrickDamage.some: 'elementWood015.png',
     
BrickDamage.lots: 'elementWood047.png',
   
},
   
(BrickType.explosive, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementExplosive017.png',
     
BrickDamage.some: 'elementExplosive022.png',
     
BrickDamage.lots: 'elementExplosive052.png',
   
},
   
(BrickType.glass, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementGlass018.png',
     
BrickDamage.some: 'elementGlass023.png',
     
BrickDamage.lots: 'elementGlass050.png',
   
},
   
(BrickType.metal, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementMetal017.png',
     
BrickDamage.some: 'elementMetal022.png',
     
BrickDamage.lots: 'elementMetal052.png',
   
},
   
(BrickType.stone, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementStone017.png',
     
BrickDamage.some: 'elementStone022.png',
     
BrickDamage.lots: 'elementStone049.png',
   
},
   
(BrickType.wood, BrickSize.size70x140) => {
     
BrickDamage.none: 'elementWood016.png',
     
BrickDamage.some: 'elementWood021.png',
     
BrickDamage.lots: 'elementWood048.png',
   
},
   
(BrickType.explosive, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementExplosive018.png',
     
BrickDamage.some: 'elementExplosive023.png',
     
BrickDamage.lots: 'elementExplosive053.png',
   
},
   
(BrickType.glass, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementGlass019.png',
     
BrickDamage.some: 'elementGlass024.png',
     
BrickDamage.lots: 'elementGlass051.png',
   
},
   
(BrickType.metal, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementMetal018.png',
     
BrickDamage.some: 'elementMetal023.png',
     
BrickDamage.lots: 'elementMetal053.png',
   
},
   
(BrickType.stone, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementStone018.png',
     
BrickDamage.some: 'elementStone023.png',
     
BrickDamage.lots: 'elementStone050.png',
   
},
   
(BrickType.wood, BrickSize.size140x140) => {
     
BrickDamage.none: 'elementWood017.png',
     
BrickDamage.some: 'elementWood022.png',
     
BrickDamage.lots: 'elementWood049.png',
   
},
   
(BrickType.explosive, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementExplosive019.png',
     
BrickDamage.some: 'elementExplosive024.png',
     
BrickDamage.lots: 'elementExplosive054.png',
   
},
   
(BrickType.glass, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementGlass020.png',
     
BrickDamage.some: 'elementGlass025.png',
     
BrickDamage.lots: 'elementGlass052.png',
   
},
   
(BrickType.metal, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementMetal019.png',
     
BrickDamage.some: 'elementMetal024.png',
     
BrickDamage.lots: 'elementMetal054.png',
   
},
   
(BrickType.stone, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementStone019.png',
     
BrickDamage.some: 'elementStone024.png',
     
BrickDamage.lots: 'elementStone051.png',
   
},
   
(BrickType.wood, BrickSize.size220x140) => {
     
BrickDamage.none: 'elementWood018.png',
     
BrickDamage.some: 'elementWood023.png',
     
BrickDamage.lots: 'elementWood050.png',
   
},
   
(BrickType.explosive, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementExplosive020.png',
     
BrickDamage.some: 'elementExplosive025.png',
     
BrickDamage.lots: 'elementExplosive055.png',
   
},
   
(BrickType.glass, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementGlass021.png',
     
BrickDamage.some: 'elementGlass026.png',
     
BrickDamage.lots: 'elementGlass053.png',
   
},
   
(BrickType.metal, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementMetal020.png',
     
BrickDamage.some: 'elementMetal025.png',
     
BrickDamage.lots: 'elementMetal055.png',
   
},
   
(BrickType.stone, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementStone020.png',
     
BrickDamage.some: 'elementStone025.png',
     
BrickDamage.lots: 'elementStone052.png',
   
},
   
(BrickType.wood, BrickSize.size70x220) => {
     
BrickDamage.none: 'elementWood019.png',
     
BrickDamage.some: 'elementWood024.png',
     
BrickDamage.lots: 'elementWood051.png',
   
},
   
(BrickType.explosive, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementExplosive021.png',
     
BrickDamage.some: 'elementExplosive026.png',
     
BrickDamage.lots: 'elementExplosive056.png',
   
},
   
(BrickType.glass, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementGlass022.png',
     
BrickDamage.some: 'elementGlass027.png',
     
BrickDamage.lots: 'elementGlass054.png',
   
},
   
(BrickType.metal, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementMetal021.png',
     
BrickDamage.some: 'elementMetal026.png',
     
BrickDamage.lots: 'elementMetal056.png',
   
},
   
(BrickType.stone, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementStone021.png',
     
BrickDamage.some: 'elementStone026.png',
     
BrickDamage.lots: 'elementStone053.png',
   
},
   
(BrickType.wood, BrickSize.size140x220) => {
     
BrickDamage.none: 'elementWood020.png',
     
BrickDamage.some: 'elementWood025.png',
     
BrickDamage.lots: 'elementWood052.png',
   
},
 
};
}

class Brick extends BodyComponent {
 
Brick({
   
required this.type,
   
required this.size,
   
required BrickDamage damage,
   
required Vector2 position,
   
required Map<BrickDamage, Sprite> sprites,
 
}) : _damage = damage,
       
_sprites = sprites,
       
super(
         
renderBody: false,
         
bodyDef: BodyDef()
           
..position = position
           
..type = BodyType.dynamic,
         
fixtureDefs: [
           
FixtureDef(
               
PolygonShape()..setAsBoxXY(
                 
size.size.width / 20 * brickScale,
                 
size.size.height / 20 * brickScale,
               
),
             
)
             
..restitution = 0.4
             
..density = type.density
             
..friction = type.friction,
         
],
       
);

 
late final SpriteComponent _spriteComponent;

 
final BrickType type;
 
final BrickSize size;
 
final Map<BrickDamage, Sprite> _sprites;

 
BrickDamage _damage;
 
BrickDamage get damage => _damage;
 
set damage(BrickDamage value) {
   
_damage = value;
   
_spriteComponent.sprite = _sprites[value];
 
}

 
@override
 
Future<void> onLoad() {
   
_spriteComponent = SpriteComponent(
     
anchor: Anchor.center,
     
scale: Vector2.all(1),
     
sprite: _sprites[_damage],
     
size: size.size.toVector2() / 10 * brickScale,
     
position: Vector2(0, 0),
   
);
   
add(_spriteComponent);
   
return super.onLoad();
 
}
}

Теперь вы можете увидеть, как сгенерированный ранее код Dart интегрируется в эту кодовую базу, чтобы сделать выбор изображений кирпичей быстрым на основе материала, размера и состояния. Просматривая enum s и сам компонент Brick , вы должны обнаружить, что большая часть этого кода кажется довольно знакомой по компоненту Ground на предыдущем шаге. Здесь есть изменяемое состояние, позволяющее кирпичу быть поврежденным, хотя использование этого оставлено в качестве упражнения для читателя.

Время вывести кирпичи на экран. Отредактируйте файл game.dart следующим образом:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());                                // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();                                // Add from here...

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}                                                        // To here.
}

Это добавление кода немного отличается от кода, который вы использовали для добавления компонентов Ground . На этот раз Brick добавляются случайным кластером с течением времени. Это состоит из двух частей: первая заключается в том, что метод, который добавляет Brick , await sa Future.delayed , что является асинхронным эквивалентом вызова sleep() . Однако есть и вторая часть, чтобы это работало: вызов addBricks в методе onLoad не await ed. Если бы это было так, метод onLoad не завершился бы, пока все bricks не оказались на экране. Оборачивание вызова addBricks в unawaited вызов радует линтеры и делает наши намерения очевидными для будущих программистов. Недождитесь возврата этого метода намеренно.

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

Окно приложения с зелеными холмами на заднем плане, слоем земли и приземляющимися на землю блоками.

6. Добавить игрока

Кидайте инопланетян в кирпичи

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

Создайте новый файл player.dart в каталоге lib/components и добавьте в него следующее:

lib/components/player.dart

import 'dart:math';

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

const playerSize = 5.0;

enum PlayerColor {
 
pink,
 
blue,
 
green,
 
yellow;

 
static PlayerColor get randomColor =>
     
PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

 
String get fileName =>
     
'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
 
Player(Vector2 position, Sprite sprite)
   
: _sprite = sprite,
     
super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static
         
..angularDamping = 0.1
         
..linearDamping = 0.1,
       
fixtureDefs: [
         
FixtureDef(CircleShape()..radius = playerSize / 2)
           
..restitution = 0.4
           
..density = 0.75
           
..friction = 0.5,
       
],
     
);

 
final Sprite _sprite;

 
@override
 
Future<void> onLoad() {
   
addAll([
     
CustomPainterComponent(
       
painter: _DragPainter(this),
       
anchor: Anchor.center,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
     
SpriteComponent(
       
anchor: Anchor.center,
       
sprite: _sprite,
       
size: Vector2(playerSize, playerSize),
       
position: Vector2(0, 0),
     
),
   
]);
   
return super.onLoad();
 
}

 
@override
 
void update(double dt) {
   
super.update(dt);

   
if (!body.isAwake) {
     
removeFromParent();
   
}

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}

 
Vector2 _dragStart = Vector2.zero();
 
Vector2 _dragDelta = Vector2.zero();
 
Vector2 get dragDelta => _dragDelta;

 
@override
 
void onDragStart(DragStartEvent event) {
   
super.onDragStart(event);
   
if (body.bodyType == BodyType.static) {
     
_dragStart = event.localPosition;
   
}
 
}

 
@override
 
void onDragUpdate(DragUpdateEvent event) {
   
if (body.bodyType == BodyType.static) {
     
_dragDelta = event.localEndPosition - _dragStart;
   
}
 
}

 
@override
 
void onDragEnd(DragEndEvent event) {
   
super.onDragEnd(event);
   
if (body.bodyType == BodyType.static) {
     
children
         
.whereType<CustomPainterComponent>()
         
.firstOrNull
         
?.removeFromParent();
     
body.setType(BodyType.dynamic);
     
body.applyLinearImpulse(_dragDelta * -50);
     
add(RemoveEffect(delay: 5.0));
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
 
_DragPainter(this.player);

 
final Player player;

 
@override
 
void paint(Canvas canvas, Size size) {
   
if (player.dragDelta != Vector2.zero()) {
     
var center = size.center(Offset.zero);
     
canvas.drawLine(
       
center,
       
center + (player.dragDelta * -1).toOffset(),
       
Paint()
         
..color = Colors.orange.withAlpha(180)
         
..strokeWidth = 0.4
         
..strokeCap = StrokeCap.round,
     
);
   
}
 
}

 
@override
 
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Это шаг вперед по сравнению с компонентами Brick на предыдущем шаге. У этого компонента Player есть два дочерних компонента: SpriteComponent , который вы должны знать, и CustomPainterComponent , который является новым. Концепция CustomPainter взята из Flutter и позволяет рисовать на холсте. Здесь она используется, чтобы дать игроку обратную связь о том, куда полетит круглый инопланетянин, когда его бросят.

Как игрок инициирует бросок инопланетянина? Используя жест перетаскивания, который компонент Player обнаруживает с помощью обратных вызовов DragCallbacks . Орлиный глаз среди вас заметил здесь кое-что еще.

Если компоненты Ground были статическими телами, то компоненты Brick были динамическими телами. Player здесь представляет собой комбинацию того и другого. Игрок начинается как статический, ожидая, пока игрок его перетащит, и при отпускании перетаскивания он преобразуется из статического в динамический, добавляет линейный импульс пропорционально перетаскиванию и позволяет инопланетному аватару летать!

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

Интегрируйте компонент Player в игру, отредактировав game.dart следующим образом:

lib/components/game.dart

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

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks());
   
await addPlayer();                                     // Add this line

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(             // Add from here...
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted && world.children.whereType<Player>().isEmpty) {
     
addPlayer();
   
}
 
}                                                        // To here.
}

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

Окно приложения с зелеными холмами на заднем плане, слоем земли, блоками на земле и аватаром игрока в полете.

7. Реагировать на воздействие

Добавьте врагов.

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

Создайте файл enemy.dart в каталоге lib/components и добавьте следующее:

lib/components/enemy.dart

import 'dart:math';

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

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
 
pink(color: 'pink', boss: false),
 
blue(color: 'blue', boss: false),
 
green(color: 'green', boss: false),
 
yellow(color: 'yellow', boss: false),
 
pinkBoss(color: 'pink', boss: true),
 
blueBoss(color: 'blue', boss: true),
 
greenBoss(color: 'green', boss: true),
 
yellowBoss(color: 'yellow', boss: true);

 
final bool boss;
 
final String color;

 
const EnemyColor({required this.color, required this.boss});

 
static EnemyColor get randomColor =>
     
EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

 
String get fileName =>
     
'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
 
Enemy(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.dynamic,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(enemySize),
           
position: Vector2(0, 0),
         
),
       
],
     
);

 
@override
 
void beginContact(Object other, Contact contact) {
   
var interceptVelocity =
       
(contact.bodyA.linearVelocity - contact.bodyB.linearVelocity).length
           
.abs();
   
if (interceptVelocity > 35) {
     
removeFromParent();
   
}

   
super.beginContact(other, contact);
 
}

 
@override
 
void update(double dt) {
   
super.update(dt);

   
if (position.x > camera.visibleWorldRect.right + 10 ||
       
position.x < camera.visibleWorldRect.left - 10) {
     
removeFromParent();
   
}
 
}
}

extension on String {
 
String get capitalize =>
     
characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

Из вашего предыдущего взаимодействия с компонентами Player и Brick большая часть этого файла должна быть вам знакома. Однако в вашем редакторе будет несколько красных подчеркиваний из-за нового неизвестного базового класса. Добавьте этот класс сейчас, добавив файл с именем body_component_with_user_data.dart в lib/components со следующим содержимым:

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
 
BodyComponentWithUserData({
   
super.key,
   
super.bodyDef,
   
super.children,
   
super.fixtureDefs,
   
super.paint,
   
super.priority,
   
super.renderBody,
 
});

 
@override
 
Body createBody() {
   
final body = world.createBody(super.bodyDef!)..userData = this;
   
fixtureDefs?.forEach(body.createFixture);
   
return body;
 
}
}

Этот базовый класс в сочетании с новым обратным вызовом beginContact в компоненте Enemy формирует основу для программного получения уведомлений о столкновениях между телами. Фактически, вам нужно будет отредактировать все компоненты, между которыми вы хотите получать уведомления о столкновениях. Итак, продолжайте и отредактируйте компоненты Brick , Ground и Player чтобы использовать этот BodyComponentWithUserData вместо базового класса BodyComponent , который используют эти компоненты. Например, вот как отредактировать компонент Ground :

lib/components/ground.dart

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

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
 
Ground(Vector2 position, Sprite sprite)
   
: super(
       
renderBody: false,
       
bodyDef: BodyDef()
         
..position = position
         
..type = BodyType.static,
       
fixtureDefs: [
         
FixtureDef(
           
PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
           
friction: 0.3,
         
),
       
],
       
children: [
         
SpriteComponent(
           
anchor: Anchor.center,
           
sprite: sprite,
           
size: Vector2.all(groundSize),
           
position: Vector2(0, 0),
         
),
       
],
     
);
}

Дополнительную информацию о том, как Forge2d обрабатывает контакты, см. в документации Forge2D по обратным вызовам контактов .

Выиграй игру

Теперь, когда у вас есть враги и способ убрать врагов из мира, есть простой способ превратить эту симуляцию в игру. Поставьте себе цель убрать всех врагов! Время редактировать файл game.dart следующим образом:

lib/components/game.dart

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

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

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
 
MyPhysicsGame()
   
: super(
       
gravity: Vector2(0, 10),
       
camera: CameraComponent.withFixedResolution(width: 800, height: 600),
     
);

 
late final XmlSpriteSheet aliens;
 
late final XmlSpriteSheet elements;
 
late final XmlSpriteSheet tiles;

 
@override
 
FutureOr<void> onLoad() async {
   
final backgroundImage = await images.load('colored_grass.png');
   
final spriteSheets = await Future.wait([
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_aliens.png',
       
xmlPath: 'spritesheet_aliens.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_elements.png',
       
xmlPath: 'spritesheet_elements.xml',
     
),
     
XmlSpriteSheet.load(
       
imagePath: 'spritesheet_tiles.png',
       
xmlPath: 'spritesheet_tiles.xml',
     
),
   
]);

   
aliens = spriteSheets[0];
   
elements = spriteSheets[1];
   
tiles = spriteSheets[2];

   
await world.add(Background(sprite: Sprite(backgroundImage)));
   
await addGround();
   
unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
   
await addPlayer();

   
return super.onLoad();
 
}

 
Future<void> addGround() {
   
return world.addAll([
     
for (
       
var x = camera.visibleWorldRect.left;
       
x < camera.visibleWorldRect.right + groundSize;
       
x += groundSize
     
)
       
Ground(
         
Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
         
tiles.getSprite('grass.png'),
       
),
   
]);
 
}

 
final _random = Random();

 
Future<void> addBricks() async {
   
for (var i = 0; i < 5; i++) {
     
final type = BrickType.randomType;
     
final size = BrickSize.randomSize;
     
await world.add(
       
Brick(
         
type: type,
         
size: size,
         
damage: BrickDamage.some,
         
position: Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 5 - 2.5),
           
0,
         
),
         
sprites: brickFileNames(
           
type,
           
size,
         
).map((key, filename) => MapEntry(key, elements.getSprite(filename))),
       
),
     
);
     
await Future<void>.delayed(const Duration(milliseconds: 500));
   
}
 
}

 
Future<void> addPlayer() async => world.add(
   
Player(
     
Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
     
aliens.getSprite(PlayerColor.randomColor.fileName),
   
),
 
);

 
@override
 
void update(double dt) {
   
super.update(dt);
   
if (isMounted &&                                       // Modify from here...
       
world.children.whereType<Player>().isEmpty &&
       
world.children.whereType<Enemy>().isNotEmpty) {
     
addPlayer();
   
}
   
if (isMounted &&
       
enemiesFullyAdded &&
       
world.children.whereType<Enemy>().isEmpty &&
       
world.children.whereType<TextComponent>().isEmpty) {
     
world.addAll(
       
[
         
(position: Vector2(0.5, 0.5), color: Colors.white),
         
(position: Vector2.zero(), color: Colors.orangeAccent),
       
].map(
         
(e) => TextComponent(
           
text: 'You win!',
           
anchor: Anchor.center,
           
position: e.position,
           
textRenderer: TextPaint(
             
style: TextStyle(color: e.color, fontSize: 16),
           
),
         
),
       
),
     
);
   
}
 
}

 
var enemiesFullyAdded = false;

 
Future<void> addEnemies() async {
   
await Future<void>.delayed(const Duration(seconds: 2));
   
for (var i = 0; i < 3; i++) {
     
await world.add(
       
Enemy(
         
Vector2(
           
camera.visibleWorldRect.right / 3 +
               
(_random.nextDouble() * 7 - 3.5),
           
(_random.nextDouble() * 3),
         
),
         
aliens.getSprite(EnemyColor.randomColor.fileName),
       
),
     
);
     
await Future<void>.delayed(const Duration(seconds: 1));
   
}
   
enemiesFullyAdded = true;                              // To here.
 
}
}

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

Окно приложения с зелеными холмами на заднем плане, слоем земли, блоками на земле и текстовым наложением «Вы победили!»

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

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

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

Что дальше?

Ознакомьтесь с некоторыми из этих лабораторных работ...

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