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

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

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

Анимация игры в этой 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.
  1. Измените зависимости проекта, добавив Flame и Forge2D:
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 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 следующим:

библиотека/main.dart

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

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

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

Необязательно: выполните побочный квест только для macOS.

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

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

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

бен/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. Добавьте графические ресурсы

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

Любая игра нуждается в художественных ресурсах, чтобы иметь возможность раскрашивать экран так, чтобы это приносило удовольствие. В этой кодовой лаборатории будет использоваться пакет 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.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

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

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

$ mkdir -p assets/images

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

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

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

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

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

Расширен список файлов пакета kenney_physical-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 с выделенным каталогом ресурсов.

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

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

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

lib/компоненты/background.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. В этом коде есть несколько упрощающих допущений. Во-первых, изображения квадратные, как и все четыре фоновых изображения Кенни. Во-вторых, размер видимого мира никогда не изменится, иначе этому компоненту пришлось бы обрабатывать события изменения размера игры. Третье предположение заключается в том, что позиция (0,0) будет в центре экрана. Эти предположения требуют определенной настройки CameraComponent игры.

Создайте еще один новый файл с именем 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';

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.wait . Список возвращенных изображений затем сопоставляется с отдельными именами по шаблону.

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

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

библиотека/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() добавит почтовый ящик, необходимый для того, чтобы соотношение 800 на 600 работало в игре.

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

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

Что-то, на чем можно основываться

Если у нас есть гравитация, нам нужно что-то, что могло бы ловить объекты в игре, прежде чем они упадут с нижней части экрана. Если, конечно, падение с экрана не является частью вашего игрового дизайна. Создайте новый файл ground.dart в каталоге 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 следующим образом.

библиотека/компоненты/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 со следующим содержимым:

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

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

библиотека/компоненты/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 . Если бы это было так, метод onLoad не завершился бы до тех пор, пока все кирпичики не окажутся на экране. Обертывание вызова addBricks в unawaited вызов делает линтеры счастливыми и делает наше намерение очевидным для будущих программистов. Не ждать возврата этого метода намеренно.

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

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

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

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

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

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

lib/компоненты/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.withOpacity(0.7)
            ..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 в игру, отредактировав game.dart следующим образом:

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

библиотека/компоненты/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 следующим образом:

библиотека/компоненты/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 для анимации и удаления компонентов. Вы использовали пакеты Google Fonts и Flutter Animate, чтобы вся игра выглядела хорошо.

Что дальше?

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

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