1. Прежде чем начать
Flame — это 2D-игровой движок на основе Flutter. В этой лаборатории кода вы создадите игру, которая использует 2D-симуляцию физики по принципу Box2D, под названием Forge2D . Вы используете компоненты Flame для рисования на экране моделируемой физической реальности, с которой могут играть ваши пользователи. По завершении ваша игра должна выглядеть так:
Предварительные условия
- Завершение курса «Введение в Flame с помощью Flutter».
Что вы узнаете
- Как работают основы Forge2D, начиная с различных типов физических тел.
- Как настроить физическую симуляцию в 2D.
Что вам нужно
- Флаттер SDK
- Код Visual Studio (VS Code) с плагинами Flutter и Dart
Программное обеспечение-компилятор для выбранной вами цели разработки. Эта кодовая лаборатория работает для всех шести платформ, которые поддерживает Flutter. Вам нужна Visual Studio для Windows, Xcode для macOS или iOS и Android Studio для Android.
2. Создать проект
Создайте свой проект Flutter
Есть много способов создать проект Flutter. В этом разделе для краткости вы используете командную строку.
Для начала выполните следующие действия:
- В командной строке создайте проект 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.
- Измените зависимости проекта, добавив 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, убрав строку заголовка.
Для этого выполните следующие действия:
- Создайте файл
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
, поскольку он не является частью базы кода среды выполнения игры. Это инструмент командной строки, используемый для изменения проекта.
- Из базового каталога проекта запустите инструмент следующим образом:
$ 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
, и вы увидите что-то вроде этого:
Из каталога PNG/Backgrounds
скопируйте файлы colored_desert.png
, colored_grass.png
, colored_land.png
и colored_shroom.png
в каталог assets/images
вашего проекта.
Есть также спрайт-листы. Это комбинация изображения PNG и файла XML, который описывает, где в изображении таблицы спрайтов можно найти изображения меньшего размера. Спрайт-таблицы — это метод сокращения времени загрузки за счет загрузки только одного файла, а не десятков, если не сотен отдельных файлов изображений.
Скопируйте файлы spritesheet_aliens.png
, spritesheet_elements.png
и spritesheet_tiles.png
в каталог assets/images
вашего проекта. Пока вы здесь, также скопируйте файлы spritesheet_aliens.xml
, spritesheet_elements.xml
и spritesheet_tiles.xml
в каталог assets
вашего проекта. Ваш проект должен выглядеть следующим образом.
Нарисуйте фон
Теперь, когда в ваш проект добавлены графические ресурсы, пришло время вывести их на экран. Ну, одно изображение на экране. Больше будет в следующих шагах.
Создайте файл с именем 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, чтобы вся игра выглядела хорошо.
Что дальше?
Посмотрите некоторые из этих кодовых лабораторий...
- Создание пользовательских интерфейсов нового поколения во Flutter
- Превратите свое приложение Flutter из скучного в красивое
- Добавление встроенных покупок в ваше приложение Flutter