Пример простой структуры игры:
FlameGame
├── World
│ ├── Player
│ └── Enemy
└── CameraComponent
├── Viewfinder
│ ├── HudButton
│ └── FpsTextComponent
└── Viewport
Чтобы понять, как работает CameraComponent, представьте, что ваш игровой мир — это сущность, существующая где-то независимо от вашего приложения. Представьте, что ваша игра — это всего лишь окно, через которое вы можете заглянуть в этот мир. Что вы можете закрыть это окно в любой момент, и игровой мир все равно будет там. Или, наоборот, вы можете открыть несколько окон, которые одновременно смотрят на один и тот же мир (или на разные миры).
С таким мышлением мы теперь можем понять, как работает CameraComponent.
Во-первых, есть класс World, который содержит все компоненты, находящиеся внутри вашего игрового мира. Компонент World можно установить где угодно, например, в корне вашего игрового класса, как и встроенный World.
Затем, класс CameraComponent, который "смотрит" на World. CameraComponent имеет внутри себя Viewport и Viewfinder, что обеспечивает гибкость рендеринга мира в любом месте экрана, а также позволяет контролировать местоположение и угол обзора. CameraComponent также содержит компонент backdrop, который статически отрисовывается под миром.
Этот компонент следует использовать для размещения всех других компонентов, составляющих ваш игровой мир. Главное свойство класса World заключается в том, что он не отображается традиционными средствами – вместо этого он отображается одним или несколькими CameraComponent, чтобы "смотреть" на мир. В классе FlameGame есть один World под названием world, который добавляется по умолчанию и сопряжен с CameraComponent по умолчанию, называемой camera.
У игры может быть несколько экземпляров World, которые можно отображать либо одновременно, либо в разное время. Например, если у вас есть два мира A и B и одна камера, то переключение цели этой камеры с A на B мгновенно переключит вид на мир B без необходимости удалять A и затем монтировать B.
Как и в случае с большинством Components, дочерние элементы можно добавлять в World, используя аргумент children в его конструкторе или используя методы add или addAll.
Для многих игр вы захотите расширить мир и создать там свою логику, такая структура игры может выглядеть следующим образом:
void main() {
runApp(GameWidget(FlameGame(world: MyWorld())));
}
class MyWorld extends World {
@override
Future<void> onLoad() async {
// Load all the assets that are needed in this world
// and add components etc.
}
}
Это компонент, через который отображается World. Несколько камер могут одновременно наблюдать за одним и тем же миром.
В классе FlameGame есть CameraComponent по умолчанию под названием camera, который сопряжен с миром по умолчанию, поэтому вам не нужно создавать или добавлять свой собственный CameraComponent, если вашей игре это не нужно.
CameraComponent имеет внутри себя два других компонента: Viewport и Viewfinder, эти компоненты всегда являются дочерними элементами камеры.
Класс FlameGame имеет поле camera в своем конструкторе, поэтому вы можете установить, какой тип камеры по умолчанию вам нужен, например, эта камера с фиксированным разрешением:
void main() {
runApp(
GameWidget(
FlameGame(
camera: CameraComponent.withFixedResolution(
width: 800,
height: 600,
),
world: MyWorld(),
),
),
);
}
Существует также статическое свойство CameraComponent.currentCamera, которое возвращает объект камеры, который в данный момент выполняет рендеринг. Это необходимо только для определенных продвинутых случаев использования, когда рендеринг компонента зависит от настроек камеры. Например, некоторые компоненты могут решить пропустить свой рендеринг и рендеринг своих дочерних элементов, если они находятся за пределами области просмотра камеры.
Этот именованный конструктор позволит вам притвориться, что устройство пользователя имеет фиксированное разрешение по вашему выбору. Например:
final camera = CameraComponent.withFixedResolution(
world: myWorldComponent,
width: 800,
height: 600,
);
Это создаст камеру с видовым экраном, центрированным посередине экрана, занимающим как можно больше места, сохраняя при этом соотношение сторон 4:3 (800x600) и показывающим область игрового мира размером 800 x 600.
С "фиксированным разрешением" очень просто работать, но оно будет недостаточно эффективно использовать доступное пространство экрана пользователя, если только его устройство не имеет такое же соотношение сторон, как и выбранные вами размеры.
Viewport — это окно, через которое виден World. Это окно имеет определенный размер, форму и положение на экране. Доступно несколько видов видовых экранов, и вы всегда можете реализовать свой собственный.
Viewport — это компонент, что означает, что вы можете добавлять к нему другие компоненты. На эти дочерние компоненты будет влиять положение видового экрана, но не его маска обрезки. Таким образом, если видовой экран является "окном" в игровой мир, то его дочерние элементы — это вещи, которые вы можете поместить поверх окна.
Добавление элементов в видовой экран — это удобный способ реализации компонентов "HUD".
Доступны следующие видовые экраны:
• MaxViewport (по умолчанию) – этот видовой экран расширяется до максимального размера, разрешенного игрой, т.е. он будет равен размеру игрового холста.
• FixedResolutionViewport – сохраняет фиксированное разрешение и соотношение сторон, с черными полосами по бокам, если оно не соответствует соотношению сторон.
• FixedSizeViewport – простой прямоугольный видовой экран с предопределенным размером.
• FixedAspectRatioViewport – прямоугольный видовой экран, который расширяется, чтобы поместиться в игровой холст, но сохраняет свое соотношение сторон.
• CircularViewport – видовой экран в форме круга, фиксированного размера.
Если вы добавите дочерние элементы в Viewport, они появятся как статические HUD перед миром.
Эта часть камеры отвечает за то, чтобы знать, на какое место в базовом игровом мире мы в данный момент смотрим. Viewfinder также контролирует уровень масштабирования и угол поворота вида.
Свойство anchor видоискателя позволяет вам указать, какая точка внутри видового экрана служит "логическим центром" камеры. Например, в сайд-скроллинговых экшен-играх обычно камера фокусируется на главном персонаже, который отображается не в центре экрана, а ближе к нижнему левому углу. Это смещенное положение будет "логическим центром" камеры, контролируемым якорем видоискателя.
Если вы добавите дочерние элементы в Viewfinder, они появятся перед миром, но за видовым экраном и с теми же преобразованиями, которые применяются к миру, поэтому эти компоненты не являются статическими.
Вы также можете добавить поведенческие компоненты в качестве дочерних элементов к видоискателю, например, эффекты или другие контроллеры. Если вы, например, добавите ScaleEffect, вы сможете добиться плавного увеличения в своей игре.
Чтобы добавить статические компоненты за миром, вы можете добавить их в компонент backdrop или заменить компонент backdrop. Это полезно, например, если вы хотите иметь статический ParallaxComponent, который отображается за миром, содержащим игрока, который может перемещаться.
Пример:
camera.backdrop.add(MyStaticBackground());
// или
camera.backdrop = MyStaticBackground();
Есть несколько способов изменить настройки камеры во время выполнения:
CameraComponent имеет несколько методов для управления своим поведением:
• follow() заставит камеру следовать за предоставленной целью. При желании вы можете ограничить максимальную скорость движения камеры или разрешить ей двигаться только по горизонтали/вертикали.
• stop() отменит эффект предыдущего вызова и остановит камеру в ее текущем положении.
• moveBy() можно использовать для перемещения камеры на указанное смещение. Если камера уже следовала за другим компонентом или двигалась, эти поведения будут автоматически отменены.
• moveTo() можно использовать для перемещения камеры в указанную точку на карте мира. Если камера уже следовала за другим компонентом или двигалась к другой точке, эти поведения будут автоматически отменены.
• setBounds() позволяет добавлять ограничения на то, куда может двигаться камера. Эти ограничения имеют форму Shape, которая обычно является прямоугольником, но также может быть любой другой формой.
Камера предоставляет свойство visibleWorldRect, которое представляет собой прямоугольник, описывающий область мира, которая в данный момент видна через камеру. Эту область можно использовать, чтобы избежать рендеринга компонентов, находящихся вне поля зрения, или реже обновлять объекты, находящиеся далеко от игрока.
visibleWorldRect — это кэшированное свойство, и оно автоматически обновляется всякий раз, когда камера перемещается или видовой экран меняет свой размер.
CameraComponent имеет метод canSee, который можно использовать для проверки того, виден ли компонент с точки зрения камеры. Это полезно, например, для отсечения компонентов, находящихся вне поля зрения.
if (!camera.canSee(component)) {
component.removeFromParent(); // Отсечь компонент
}
Цель: Создать игру с несколькими уникальными мирами, соединенными порталами.
Игрок должен иметь возможность перемещаться между мирами, используя порталы.
Задачи:
Подсказки:
import 'package:flame/components.dart';
import 'package:flame/input.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flame/game.dart';
import 'package:flame/events.dart';
// Можете не трогать этот код до линии ниже
class Player extends CircleComponent with KeyboardHandler {
final double speed = 200;
Vector2 worldSize;
Player({
required this.worldSize,
required Vector2 position,
required double radius,
required Color color,
}) : super(
position: position,
radius: radius,
paint: Paint()..color = color,
priority: 1,
);
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
final direction = Vector2.zero();
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
direction.x -= 1;
}
if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
direction.x += 1;
}
if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
direction.y -= 1;
}
if (keysPressed.contains(LogicalKeyboardKey.arrowDown)) {
direction.y += 1;
}
if (direction.isZero()) return false;
direction.normalize();
position += direction * speed * 1 / 60;
position.clamp(
Vector2(radius, radius),
Vector2(worldSize.x - radius, worldSize.y - radius),
);
return true;
}
}
class Portal extends RectangleComponent with TapCallbacks {
final Vector2 targetPosition;
final int targetWorldIndex;
Portal({
required Vector2 position,
required Vector2 size,
required this.targetPosition,
required this.targetWorldIndex,
}) : super(
position: position,
size: size,
paint: Paint()..color = Colors.purple,
priority: 0,
);
@override
bool onTapDown(TapDownEvent event) {
final player = parent?.children.whereType<Player>().first;
if (player != null) {
final game = findGame() as MyPortalGame;
game.switchWorld(targetPosition, targetWorldIndex);
}
return true;
}
}
//--------------------------------------------------------------
// Фабрика для создания миров
class WorldFactory {
static World createWorld(int index) {
switch (index) {
case 0:
return MyWorld();
// TODO: Добавьте создание остальных миров
default:
throw Exception('Unknown world index: $index');
}
}
}
// Пример мира
class MyWorld extends World {
final Vector2 size = Vector2(800, 600);
@override
Future<void> onLoad() async {
// Добавляем фон
add(RectangleComponent(
position: Vector2.zero(),
size: size,
paint: Paint()..color = Colors.lightBlue,
priority: -1,
));
// TODO: Добавьте портал в другой мир
}
}
// TODO: Создайте еще два мира (SecondWorld и ThirdWorld)
class MyPortalGame extends FlameGame with HasKeyboardHandlerComponents {
late List<World> worlds;
late Player player;
int currentWorldIndex = 0;
@override
Future<void> onLoad() async {
// Тут создаем все миры
worlds = List.generate(3, (index) => WorldFactory.createWorld(index));
// Создаем игрока
player = Player(
worldSize: (worlds[0] as MyWorld).size,
position: (worlds[0] as MyWorld).size / 2,
radius: 20,
color: Colors.red,
);
// TODO: Настройте начальный мир и добавьте игрока
// TODO: Настройте камеру и отцентрируйте ее на игроке
}
@override
void update(double dt) {
super.update(dt);
if (player.isMounted) {
camera.follow(player);
}
}
Future<void> switchWorld(Vector2 targetPosition, int targetWorldIndex) async {
// Удаляем текущий мир
worlds[currentWorldIndex].removeFromParent();
// Переключаемся на целевой мир
currentWorldIndex = targetWorldIndex;
// Создаем новый экземпляр мира
worlds[currentWorldIndex] = WorldFactory.createWorld(currentWorldIndex);
// Добавляем следующий мир
final newWorld = worlds[currentWorldIndex];
await add(newWorld);
await newWorld.onLoad();
// Обновляем размер мира для игрока
player.worldSize = (newWorld as dynamic).size;
// TODO: Добавляем игрока в новый мир и обновляем его позицию
//TODO: Обновляем камеру
}
}
void main() {
runApp(GameWidget(
game: MyPortalGame(),
));
}
Задача RouterComponent - управлять навигацией между экранами в игре. По духу он похож на класс Navigator во Flutter, за исключением того, что работает с компонентами Flame, а не с виджетами Flutter.
Типичная игра обычно состоит из нескольких страниц: заставка, главная страница меню, страница настроек, титры, главная страница игры, несколько всплывающих окон и т.д. Маршрутизатор организует все эти пункты назначения и позволяет переходить между ними.
Внутри RouterComponent содержит стек маршрутов. Когда вы просите его показать маршрут, он помещается поверх всех остальных страниц в стеке. Позже вы можете использовать pop(), чтобы удалить верхнюю страницу из стека. Обращение к страницам маршрутизатора происходит по их уникальным именам.
Каждая страница в маршрутизаторе может быть либо прозрачной, либо непрозрачной. Если страница непрозрачная, то страницы ниже нее в стеке не отрисовываются и не получают событий указателя (таких как нажатия или перетаскивания). Напротив, если страница прозрачная, то страница ниже нее будет отрисована и будет получать события как обычно. Такие прозрачные страницы полезны для реализации модальных диалогов, инвентаря или UI диалогов и т.д. Если вы хотите, чтобы ваш маршрут был визуально прозрачным, но маршруты под ним не получали событий, убедитесь, что добавили фоновый компонент к вашему маршруту, который перехватывает события, используя один из миксинов захвата событий.
Пример использования:
class MyGame extends FlameGame {
late final RouterComponent router;
@override
void onLoad() {
add(
router = RouterComponent(
routes: {
'home': Route(HomePage.new),
'level-selector': Route(LevelSelectorPage.new),
'settings': Route(SettingsPage.new, transparent: true),
'pause': PauseRoute(),
'confirm-dialog': OverlayRoute.existing(),
},
initialRoute: 'home',
),
);
}
}
class PauseRoute extends Route { ... }
Примечание
Используйте hide Route, если какие-либо из импортированных вами пакетов экспортируют другой класс с именем Route
например: import 'package:flutter/material.dart' hide Route;
Компонент Route содержит информацию о содержимом конкретной страницы. Маршруты монтируются как дочерние элементы к RouterComponent.
Основным свойством Route является его builder – функция, которая создает компонент с содержимым его страницы.
Кроме того, маршруты могут быть либо прозрачными, либо непрозрачными (по умолчанию). Непрозрачный маршрут не позволяет маршруту под ним отображаться или получать события указателя, а прозрачный маршрут позволяет. Как правило, объявляйте маршрут непрозрачным, если он занимает весь экран, и прозрачным, если он должен покрывать только часть экрана.
По умолчанию маршруты сохраняют состояние компонента страницы после его удаления из стека, и функция builder вызывается только при первой активации маршрута. Установка maintainState в false удаляет компонент страницы после удаления маршрута из стека маршрутов, и функция builder вызывается каждый раз, когда маршрут активируется.
Текущий маршрут можно заменить с помощью pushReplacementNamed или pushReplacement. Каждый метод просто выполняет pop для текущего маршрута, а затем pushNamed или pushRoute.
WorldRoute — это специальный маршрут, который позволяет устанавливать активные игровые миры через маршрутизатор. Этот тип маршрута можно, например, использовать для смены уровней, реализованных как отдельные миры в вашей игре.
По умолчанию WorldRoute заменяет текущий мир новым и по умолчанию сохраняет состояние мира после удаления из стека. Если вы хотите, чтобы мир воссоздавался каждый раз при активации маршрута, установите для maintainState значение false.
Если вы не используете встроенный CameraComponent, вы можете явно передать камеру, которую хотите использовать, в конструкторе.
final router = RouterComponent(
routes: {
'level1': WorldRoute(MyWorld1.new),
'level2': WorldRoute(MyWorld2.new, maintainState: false),
},
);
class MyWorld1 extends World {
@override
Future<void> onLoad() async {
add(BackgroundComponent());
add(PlayerComponent());
}
}
class MyWorld2 extends World {
@override
Future<void> onLoad() async {
add(BackgroundComponent());
add(PlayerComponent());
add(EnemyComponent());
}
}
OverlayRoute — это специальный маршрут, который позволяет добавлять игровые оверлеи через маршрутизатор. Эти маршруты по умолчанию прозрачны.
Существует два конструктора для OverlayRoute. Первый конструктор требует функцию builder, которая описывает, как должен быть построен виджет оверлея. Второй конструктор можно использовать, когда функция builder уже указана в GameWidget:
final router = RouterComponent(
routes: {
'ok-dialog': OverlayRoute(
(context, game) {
return Center(
child: DecoratedContainer(...),
);
},
), // OverlayRoute
'confirm-dialog': OverlayRoute.existing(),
},
);
Оверлеи, которые были определены в GameWidget, даже не нужно предварительно объявлять в карте маршрутов: метод RouterComponent.pushOverlay() может сделать это за вас. После того, как маршрут оверлея был зарегистрирован, его можно активировать либо с помощью обычного метода .pushNamed(), либо с помощью .pushOverlay() - оба метода сделают одно и то же, хотя вы можете использовать второй метод, чтобы сделать более понятным в вашем коде, что добавляется оверлей, а не обычный маршрут.
Текущий оверлей можно заменить с помощью pushReplacementOverlay. Этот метод выполняет pushReplacementNamed или pushReplacement в зависимости от статуса отправляемого оверлея.
ValueRoute — это маршрут, который возвращает значение при его удалении из стека. Такие маршруты можно использовать, например, для диалоговых окон, запрашивающих обратную связь от пользователя.
Для использования ValueRoutes требуется два шага:
class YesNoDialog extends ValueRoute<bool> {
YesNoDialog(this.text) : super(value: false);
final String text;
@override
Component build() {
return PositionComponent(
children: [
RectangleComponent(),
TextComponent(text: text),
Button(
text: 'Yes',
action: () => completeWith(true),
),
Button(
text: 'No',
action: () => completeWith(false),
),
],
);
}
}
Future<void> foo() async {
final result = await game.router.pushAndWait(YesNoDialog('Вы уверены?'));
if (result) {
// ... пользователь уверен
} else {
// ... пользователь не был так уверен
}
}
Создайте игру с двумя экранами:
Подсказки:
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart' hide Route;
// Код для RoundedButton
class RoundedButton extends PositionComponent with TapCallbacks {
RoundedButton({
required this.text,
required this.action,
required Color color,
required Color borderColor,
super.position,
super.anchor = Anchor.center,
}) : _textDrawable = TextPaint(
style: const TextStyle(
fontSize: 20,
color: Color(0xFF000000),
fontWeight: FontWeight.w800,
),
).toTextPainter(text) {
size = Vector2(150, 40);
_textOffset = Offset(
(size.x - _textDrawable.width) / 2,
(size.y - _textDrawable.height) / 2,
);
_rrect = RRect.fromLTRBR(0, 0, size.x, size.y, Radius.circular(size.y / 2));
_bgPaint = Paint()..color = color;
_borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = borderColor;
}
final String text;
final void Function() action;
final TextPainter _textDrawable;
late final Offset _textOffset;
late final RRect _rrect;
late final Paint _borderPaint;
late final Paint _bgPaint;
@override
void render(Canvas canvas) {
canvas.drawRRect(_rrect, _bgPaint);
canvas.drawRRect(_rrect, _borderPaint);
_textDrawable.paint(canvas, _textOffset);
}
@override
void onTapDown(TapDownEvent event) {
scale = Vector2.all(1.05);
}
@override
void onTapUp(TapUpEvent event) {
scale = Vector2.all(1.0);
action();
}
@override
void onTapCancel(TapCancelEvent event) {
scale = Vector2.all(1.0);
}
}
// ----------------------------------
// Пример структуры:
class Game extends FlameGame {
late final RouterComponent router;
@override
Future<void> onLoad() async {
add(
router = RouterComponent(
routes: {
//TODO: Добавьте маршруты
},
initialRoute: '',
),
);
}
}
class MainMenu extends Component with HasGameReference<Game> {
// TODO: Добавьте кнопку "Начать игру"
}
class GameLevel extends Component with HasGameReference<Game> {
// TODO: Добавьте кнопку "Вернуться в меню"
}
void main() {
runApp(GameWidget(game: Game()));
}
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/geometry.dart';
import 'package:flame/rendering.dart';
import 'package:flutter/material.dart' hide Route;
void main() {
runApp(GameWidget(game: RouterGame()));
}
class RouterGame extends FlameGame {
late final RouterComponent router;
@override
Future<void> onLoad() async {
add(
router = RouterComponent(
routes: {
'home': Route(StartPage.new),
'level1': WorldRoute(Level1Page.new),
'level2': WorldRoute(Level2Page.new, maintainState: false),
'pause': PauseRoute(),
},
initialRoute: 'home',
),
);
}
}
class StartPage extends Component with HasGameReference<RouterGame> {
StartPage() {
addAll([
_logo = TextComponent(
text: 'Your Game',
textRenderer: TextPaint(
style: const TextStyle(
fontSize: 64,
color: Color(0xFFC8FFF5),
fontWeight: FontWeight.w800,
),
),
anchor: Anchor.center,
),
_button1 = RoundedButton(
text: 'Level 1',
action: () => game.router.pushNamed('level1'),
color: const Color(0xffadde6c),
borderColor: const Color(0xffedffab),
),
_button2 = RoundedButton(
text: 'Level 2',
action: () => game.router.pushNamed('level2'),
color: const Color(0xffdebe6c),
borderColor: const Color(0xfffff4c7),
),
]);
}
late final TextComponent _logo;
late final RoundedButton _button1;
late final RoundedButton _button2;
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
_logo.position = Vector2(size.x / 2, size.y / 3);
_button1.position = Vector2(size.x / 2, _logo.y + 80);
_button2.position = Vector2(size.x / 2, _logo.y + 140);
}
}
class Background extends Component {
Background(this.color);
final Color color;
@override
void render(Canvas canvas) {
canvas.drawColor(color, BlendMode.srcATop);
}
}
class RoundedButton extends PositionComponent with TapCallbacks {
RoundedButton({
required this.text,
required this.action,
required Color color,
required Color borderColor,
super.position,
super.anchor = Anchor.center,
}) : _textDrawable = TextPaint(
style: const TextStyle(
fontSize: 20,
color: Color(0xFF000000),
fontWeight: FontWeight.w800,
),
).toTextPainter(text) {
size = Vector2(150, 40);
_textOffset = Offset(
(size.x - _textDrawable.width) / 2,
(size.y - _textDrawable.height) / 2,
);
_rrect = RRect.fromLTRBR(0, 0, size.x, size.y, Radius.circular(size.y / 2));
_bgPaint = Paint()..color = color;
_borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = borderColor;
}
final String text;
final void Function() action;
final TextPainter _textDrawable;
late final Offset _textOffset;
late final RRect _rrect;
late final Paint _borderPaint;
late final Paint _bgPaint;
@override
void render(Canvas canvas) {
canvas.drawRRect(_rrect, _bgPaint);
canvas.drawRRect(_rrect, _borderPaint);
_textDrawable.paint(canvas, _textOffset);
}
@override
void onTapDown(TapDownEvent event) {
scale = Vector2.all(1.05);
}
@override
void onTapUp(TapUpEvent event) {
scale = Vector2.all(1.0);
action();
}
@override
void onTapCancel(TapCancelEvent event) {
scale = Vector2.all(1.0);
}
}
abstract class SimpleButton extends PositionComponent with TapCallbacks {
SimpleButton(this._iconPath, {super.position}) : super(size: Vector2.all(40));
final Paint _borderPaint = Paint()
..style = PaintingStyle.stroke
..color = const Color(0x66ffffff);
final Paint _iconPaint = Paint()
..style = PaintingStyle.stroke
..color = const Color(0xffaaaaaa)
..strokeWidth = 7;
final Path _iconPath;
void action();
@override
void render(Canvas canvas) {
canvas.drawRRect(
RRect.fromRectAndRadius(size.toRect(), const Radius.circular(8)),
_borderPaint,
);
canvas.drawPath(_iconPath, _iconPaint);
}
@override
void onTapDown(TapDownEvent event) {
_iconPaint.color = const Color(0xffffffff);
}
@override
void onTapUp(TapUpEvent event) {
_iconPaint.color = const Color(0xffaaaaaa);
action();
}
@override
void onTapCancel(TapCancelEvent event) {
_iconPaint.color = const Color(0xffaaaaaa);
}
}
class BackButton extends SimpleButton with HasGameReference<RouterGame> {
BackButton()
: super(
Path()
..moveTo(22, 8)
..lineTo(10, 20)
..lineTo(22, 32)
..moveTo(12, 20)
..lineTo(34, 20),
position: Vector2.all(10),
);
@override
void action() => game.router.pop();
}
class PauseButton extends SimpleButton with HasGameReference<RouterGame> {
PauseButton()
: super(
Path()
..moveTo(14, 10)
..lineTo(14, 30)
..moveTo(26, 10)
..lineTo(26, 30),
position: Vector2(60, 10),
);
bool isPaused = false;
@override
void action() {
if (isPaused) {
game.router.pop();
} else {
game.router.pushNamed('pause');
}
isPaused = !isPaused;
}
}
class Level1Page extends DecoratedWorld with HasGameReference {
@override
Future<void> onLoad() async {
addAll([
Background(const Color(0xbb2a074f)),
Planet(
radius: 25,
color: const Color(0xfffff188),
children: [
Orbit(
radius: 110,
revolutionPeriod: 6,
planet: Planet(
radius: 10,
color: const Color(0xff54d7b1),
children: [
Orbit(
radius: 25,
revolutionPeriod: 5,
planet: Planet(radius: 3, color: const Color(0xFFcccccc)),
),
],
),
),
],
),
]);
}
final hudComponents = <Component>[];
@override
void onMount() {
hudComponents.addAll([
BackButton(),
PauseButton(),
]);
game.camera.viewport.addAll(hudComponents);
}
@override
void onRemove() {
game.camera.viewport.removeAll(hudComponents);
super.onRemove();
}
}
class Level2Page extends DecoratedWorld with HasGameReference {
@override
Future<void> onLoad() async {
addAll([
Background(const Color(0xff052b44)),
Planet(
radius: 30,
color: const Color(0xFFFFFFff),
children: [
Orbit(
radius: 60,
revolutionPeriod: 5,
planet: Planet(radius: 10, color: const Color(0xffc9ce0d)),
),
Orbit(
radius: 110,
revolutionPeriod: 10,
planet: Planet(
radius: 14,
color: const Color(0xfff32727),
children: [
Orbit(
radius: 26,
revolutionPeriod: 3,
planet: Planet(radius: 5, color: const Color(0xffffdb00)),
),
Orbit(
radius: 35,
revolutionPeriod: 4,
planet: Planet(radius: 3, color: const Color(0xffdc00ff)),
),
],
),
),
],
),
]);
}
final hudComponents = <Component>[];
@override
void onMount() {
hudComponents.addAll([
BackButton(),
PauseButton(),
]);
game.camera.viewport.addAll(hudComponents);
}
@override
void onRemove() {
game.camera.viewport.removeAll(hudComponents);
super.onRemove();
}
}
class Planet extends PositionComponent {
Planet({
required this.radius,
required this.color,
super.position,
super.children,
}) : _paint = Paint()..color = color;
final double radius;
final Color color;
final Paint _paint;
@override
void render(Canvas canvas) {
canvas.drawCircle(Offset.zero, radius, _paint);
}
}
class Orbit extends PositionComponent {
Orbit({
required this.radius,
required this.planet,
required this.revolutionPeriod,
double initialAngle = 0,
}) : _paint = Paint()
..style = PaintingStyle.stroke
..color = const Color(0x888888aa),
_angle = initialAngle {
add(planet);
}
final double radius;
final double revolutionPeriod;
final Planet planet;
final Paint _paint;
double _angle;
@override
void render(Canvas canvas) {
canvas.drawCircle(Offset.zero, radius, _paint);
}
@override
void update(double dt) {
_angle += dt / revolutionPeriod * tau;
planet.position = Vector2(radius, 0)..rotate(_angle);
}
}
class PauseRoute extends Route {
PauseRoute() : super(PausePage.new, transparent: true);
@override
void onPush(Route? previousRoute) {
if (previousRoute is WorldRoute && previousRoute.world is DecoratedWorld) {
(previousRoute.world! as DecoratedWorld).timeScale = 0;
(previousRoute.world! as DecoratedWorld).decorator =
PaintDecorator.grayscale(opacity: 0.5)..addBlur(3.0);
}
}
@override
void onPop(Route nextRoute) {
if (nextRoute is WorldRoute && nextRoute.world is DecoratedWorld) {
(nextRoute.world! as DecoratedWorld).timeScale = 1;
(nextRoute.world! as DecoratedWorld).decorator = null;
}
}
}
class PausePage extends Component
with TapCallbacks, HasGameReference<RouterGame> {
@override
Future<void> onLoad() async {
final game = findGame()!;
addAll([
TextComponent(
text: 'PAUSED',
position: game.canvasSize / 2,
anchor: Anchor.center,
children: [
ScaleEffect.to(
Vector2.all(1.1),
EffectController(
duration: 0.3,
alternate: true,
infinite: true,
),
),
],
),
]);
}
@override
bool containsLocalPoint(Vector2 point) => true;
@override
void onTapUp(TapUpEvent event) => game.router.pop();
}
class DecoratedWorld extends World with HasTimeScale {
PaintDecorator? decorator;
@override
void renderFromCamera(Canvas canvas) {
if (decorator == null) {
super.renderFromCamera(canvas);
} else {
decorator!.applyChain(super.renderFromCamera, canvas);
}
}
}
https://docs.flutter.dev/get-started/install/windows/mobile
ВАЖНО, ЧТОБЫ ПОЛЬЗОВАТЕЛЬ ОС БЫЛ НА АНГЛИЙСКОМ ЯЗЫКЕ
Flame предлагает структуру для вашего проекта, которая включает стандартный каталог ресурсов Flutter, а также некоторые дочерние элементы: audio, images и tiles.
При использовании следующего примера кода:
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
await FlameAudio.play('explosion.mp3');
// Загрузка изображений
await Flame.images.load('player.png');
await Flame.images.load('enemy.png');
// Или загрузить все изображения из вашей папки images
await Flame.images.loadAllImages();
final map1 = await TiledComponent.load('level.tmx', tileSize);
}
}
Flame ожидает, что файлы будут находиться в следующей структуре:
.
└── assets
├── audio
│ └── explosion.mp3
├── images
│ ├── enemy.png
│ ├── player.png
│ └── spritesheet.png
└── tiles
├── level.tmx
└── map.json
Опционально вы можете разделить папку audio на две подпапки: music и sfx.
Не забудьте добавить эти файлы в ваш файл pubspec.yaml:
flutter:
assets:
- assets/audio/explosion.mp3
- assets/images/player.png
- assets/images/enemy.png
- assets/tiles/level.tmx
Если вы хотите изменить эту структуру, это возможно, используя параметр prefix и создавая свои экземпляры AssetsCache, Images и AudioCache, вместо использования глобальных, предоставляемых Flame.
Кроме того, AssetsCache и Images могут принимать пользовательский AssetBundle. Это можно использовать для того, чтобы Flame искал ресурсы в другом месте, отличном от rootBundle, например, в файловой системе.
Для начала у вас должна быть соответствующая структура папок и файлы должны быть добавлены в файл pubspec.yaml, например так:
flutter:
assets:
- assets/images/player.png
- assets/images/enemy.png
Изображения могут быть в любом формате, поддерживаемом Flutter, включая: JPEG, WebP, PNG, GIF, анимированный GIF, анимированный WebP, BMP и WBMP. Другие форматы потребуют дополнительных библиотек. Например, SVG изображения можно загружать через библиотеку flame_svg.
Flame включает в себя вспомогательный класс Images, который позволяет легко загружать и кэшировать изображения из каталога assets в память.
Flutter имеет несколько типов, связанных с изображениями, и правильное преобразование всего из локального ресурса в Image, который можно нарисовать на Canvas, немного сложно. Этот класс позволяет получить Image, который можно нарисовать на Canvas, используя метод drawImageRect.
Он автоматически кэширует любое изображение, загруженное по имени файла, поэтому вы можете безопасно вызывать его много раз.
Методы для загрузки и очистки кэша: load, loadAll, clear и clearCache. Они возвращают Futures для загрузки изображений. Эти futures должны быть дожданы, прежде чем изображения можно будет использовать каким-либо образом. Если вы не хотите ждать эти futures сразу, вы можете инициировать несколько операций load() и затем дождаться их всех одновременно, используя метод Images.ready().
Чтобы синхронно получить ранее кэшированное изображение, можно использовать метод fromCache. Если изображение с этим ключом не было загружено ранее, будет выдано исключение.
Чтобы добавить уже загруженное изображение в кэш, можно использовать метод add, и вы можете установить ключ, который изображение должно иметь в кэше. Вы можете получить все ключи в кэше, используя геттер keys.
Вы также можете использовать ImageExtension.fromPixels() для динамического создания изображения во время игры.
Обратите внимание, что для clear и clearCache вызывается метод dispose для каждого удаленного изображения из кэша, поэтому убедитесь, что вы не используете изображение после этого.
Он может быть использован вручную путем создания экземпляра:
import 'package:flame/cache.dart';
final imagesLoader = Images();
Image image = await imagesLoader.load('yourImage.png');
Но Flame также предлагает два способа использования этого класса без создания его экземпляра самостоятельно.
Существует синглтон, предоставляемый классом Flame, который можно использовать как глобальный кэш изображений.
Пример:
import 'package:flame/flame.dart';
import 'package:flame/sprite.dart';
// внутри асинхронного контекста
Image image = await Flame.images.load('player.png');
final playerSprite = Sprite(image);
Класс Game предлагает некоторые вспомогательные методы для обработки загрузки изображений. Он включает экземпляр класса Images, который можно использовать для загрузки ресурсов изображений для использования во время игры. Игра автоматически освободит кэш, когда виджет игры будет удален из дерева виджетов.
Метод onLoad из класса Game - отличное место для загрузки начальных ресурсов.
Пример:
class MyGame extends Game {
Sprite player;
@override
Future<void> onLoad() async {
// Обратите внимание, что вы также можете использовать Sprite.load для этого.
final playerImage = await images.load('player.png');
player = Sprite(playerImage);
}
}
Загруженные ресурсы также можно получить во время работы игры с помощью images.fromCache, например:
class MyGame extends Game {
// атрибуты опущены
@override
Future<void> onLoad() async {
// другие загрузки опущены
await images.load('bullet.png');
}
void shoot() {
// Это всего лишь пример, в вашей игре вы, вероятно, не захотите
// создавать новые объекты [Sprite] каждый раз, когда вы стреляете.
final bulletSprite = Sprite(images.fromCache('bullet.png'));
_bullets.add(bulletSprite);
}
}
Базовый пакет Flame не предлагает встроенного метода для загрузки изображений из сети.
Причина в том, что Flutter/Dart не имеет встроенного HTTP-клиента, что требует использования пакета, и поскольку существует несколько доступных пакетов, мы воздерживаемся от принуждения пользователя к использованию определенного пакета.
Тем не менее, довольно просто загружать изображения из сети после того, как пользователь выбрал пакет HTTP-клиента. Следующий фрагмент показывает, как Image можно получить из Интернета с помощью пакета http.
import 'package:http/http.dart' as http;
import 'package:flutter/painting.dart';
final response = await http.get('https://url.com/image.png');
final image = await decodeImageFromList(response.bytes);
Примечание
Пакет flame_network_assets может быть использован для готового к использованию решения для сетевых ресурсов, которое предоставляет встроенный кэш.
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame_network_assets/flame_network_assets.dart';
import 'package:flutter/material.dart' hide Image;
void main() {
runApp(const GameWidget.controlled(gameFactory: MyGame.new));
}
class MyGame extends FlameGame with TapDetector {
final networkImages = FlameNetworkImages();
late Image playerSprite;
@override
Future<void> onLoad() async {
playerSprite = await networkImages.load(
'https://examples.flame-engine.org/assets/assets/images/bomb_ptero.png',
);
}
@override
void onTapUp(TapUpInfo info) {
add(
SpriteAnimationComponent.fromFrameData(
playerSprite,
SpriteAnimationData.sequenced(
textureSize: Vector2(48, 32),
amount: 4,
stepTime: 0.2,
),
size: Vector2(100, 50),
anchor: Anchor.center,
position: info.eventPosition.widget,
),
);
}
}
Flame предлагает класс Sprite, который представляет собой изображение или область изображения.
Вы можете создать Sprite, предоставив ему Image и координаты, определяющие часть изображения, которую представляет этот спрайт.
Например, это создаст спрайт, представляющий все изображение переданного файла:
final image = await images.load('player.png');
Sprite player = Sprite(image);
Вы также можете указать координаты в исходном изображении, где находится спрайт. Это позволяет использовать таблицы спрайтов и уменьшить количество изображений в памяти, например:
final image = await images.load('player.png');
final playerFrame = Sprite(
image,
srcPosition: Vector2(32.0, 0),
srcSize: Vector2(16.0, 16.0),
);
Значениями по умолчанию являются (0.0, 0.0) для srcPosition и null для srcSize (что означает, что будет использоваться полная ширина/высота исходного изображения).
Класс Sprite имеет метод render, который позволяет отображать спрайт на Canvas:
final image = await images.load('block.png');
Sprite block = Sprite(image);
// в вашем методе render
block.render(canvas, 16.0, 16.0); //canvas, width, height
Вы должны передать размер методу render, и размер изображения будет изменен соответствующим образом.
Все методы render из класса Sprite могут принимать экземпляр Paint в качестве необязательного именованного параметра overridePaint, этот параметр переопределит текущий экземпляр Paint спрайта для этого вызова render.
Спрайты также можно использовать как виджеты, для этого просто используйте класс SpriteWidget. Вот полный пример использования спрайтов в качестве виджетов.
Если у вас есть таблица спрайтов (также называемая атласом изображений, которая представляет собой изображение с меньшими изображениями внутри) и вы хотите эффективно ее отобразить - SpriteBatch справится с этой задачей за вас.
Передайте ему имя файла изображения, а затем добавьте прямоугольники, которые описывают различные части изображения, в дополнение к преобразованиям (позиция, масштаб и поворот) и необязательным цветам.
Вы отрисовываете его с помощью Canvas и необязательного Paint, BlendMode и CullRect.
Для вашего удобства также доступен SpriteBatchComponent.
Посмотрите, как его использовать, в примерах SpriteBatch.
В некоторых случаях может потребоваться объединить несколько изображений в одно изображение; это называется Compositing. Это может быть полезно, например, при работе с API SpriteBatch для оптимизации вызовов рисования.
Для таких случаев Flame поставляется с классом ImageComposition. Это позволяет добавлять несколько изображений, каждое в своей позиции, на новое изображение:
final composition = ImageComposition()
..add(image1, Vector2(0, 0))
..add(image2, Vector2(64, 0))
..add(image3,
Vector2(128, 0),
source: Rect.fromLTWH(32, 32, 64, 64),
);
Image image = await composition.compose();
Image imageSync = composition.composeSync();
Как видите, доступны две версии создания изображения. Используйте ImageComposition.compose() для асинхронного подхода. Или используйте новую функцию ImageComposition.composeSync(), чтобы растеризовать изображение в контексте GPU, используя преимущества функции Picture.toImageSync.
Примечание: Создание изображений обходится дорого, мы не рекомендуем запускать это каждый такт, так как это сильно влияет на производительность. Вместо этого мы рекомендуем предварительно отрисовать ваши композиции, чтобы вы могли просто повторно использовать выходное изображение.
Класс Animation помогает создавать циклическую анимацию спрайтов.
Вы можете создать ее, передав список спрайтов одинакового размера и stepTime (то есть, сколько секунд требуется для перехода к следующему кадру):
final a = SpriteAnimationTicker(SpriteAnimation.spriteList(sprites, stepTime: 0.02));
После создания анимации вам нужно вызвать ее метод update и отобразить спрайт текущего кадра в вашем игровом экземпляре.
Пример:
class MyGame extends Game {
SpriteAnimationTicker a;
MyGame() {
a = SpriteAnimationTicker(SpriteAnimation(...));
}
void update(double dt) {
a.update(dt);
}
void render(Canvas c) {
a.getSprite().render(c);
}
}
Лучшей альтернативой для создания списка спрайтов является использование конструктора fromFrameData:
const amountOfFrames = 8;
final a = SpriteAnimation.fromFrameData(
imageInstance,
SpriteAnimationFrame.sequenced(
amount: amountOfFrames,
textureSize: Vector2(16.0, 16.0),
stepTime: 0.1,
),
);
Этот конструктор позволяет очень легко создавать анимацию при использовании таблиц спрайтов.
В конструкторе вы передаете экземпляр изображения и данные кадра, которые содержат некоторые параметры, которые можно использовать для описания анимации. Проверьте документацию по конструкторам, доступным в классе SpriteAnimationFrameData, чтобы увидеть все параметры.
Если вы используете Aseprite для своих анимаций, Flame предоставляет некоторую поддержку для данных JSON анимации Aseprite. Чтобы использовать эту функцию, вам нужно экспортировать данные JSON таблицы спрайтов и использовать что-то вроде следующего фрагмента:
final image = await images.load('chopper.png');
final jsonData = await assets.readJson('chopper.json');
final animation = SpriteAnimation.fromAsepriteData(image, jsonData);
Примечание: усеченные таблицы спрайтов не поддерживаются flame, поэтому, если вы экспортируете свою таблицу спрайтов таким образом, она будет иметь усеченный размер, а не исходный размер спрайта.
Анимации, после создания, имеют метод update и render; последний отображает текущий кадр, а первый тикает внутренние часы для обновления кадров.
Анимации обычно используются внутри SpriteAnimationComponent, но также можно создавать пользовательские компоненты с несколькими анимациями.
Чтобы узнать больше, ознакомьтесь с полным примером кода использования анимаций в качестве виджетов.
Таблицы спрайтов — это большие изображения с несколькими кадрами одного и того же спрайта, и это очень хороший способ организации и хранения ваших анимаций. Flame предоставляет очень простой вспомогательный класс для работы с SpriteSheets, с помощью которого вы можете загрузить изображение таблицы спрайтов и извлечь из него анимации. Ниже приведен простой пример того, как его использовать:
import 'package:flame/sprite.dart';
final spriteSheet = SpriteSheet(
image: imageInstance,
srcSize: Vector2.all(16.0),
);
final animation = spriteSheet.createAnimation(0, stepTime: 0.1);
Теперь вы можете использовать анимацию напрямую или использовать ее в компоненте анимации.
Вы также можете создать пользовательскую анимацию, получив отдельные SpriteAnimationFrameData, используя либо SpriteSheet.createFrameData, либо SpriteSheet.createFrameDataFromId:
final animation = SpriteAnimation.fromFrameData(
imageInstance,
SpriteAnimationData([
spriteSheet.createFrameDataFromId(1, stepTime: 0.1), // by id
spriteSheet.createFrameData(2, 3, stepTime: 0.3), // row, column
spriteSheet.createFrameDataFromId(4, stepTime: 0.1), // by id
]),
);
Если вам не нужна анимация и вместо этого вам нужен только экземпляр Sprite на SpriteSheet, вы можете использовать методы getSprite или getSpriteById:
spriteSheet.getSpriteById(2); // by id
spriteSheet.getSprite(0, 0); // row, column
Посмотрите полный пример класса SpriteSheet для получения более подробной информации о том, как с ним работать.
Воспроизведение звука необходимо для большинства игр, поэтому мы сделали это простым!
Сначала вы должны добавить flame_audio в свой список зависимостей в файле pubspec.yaml:
dependencies:
flame_audio: VERSION
Последнюю версию можно найти на pub.dev.
После установки пакета flame_audio вы можете добавить аудиофайлы в раздел assets вашего файла pubspec.yaml. Убедитесь, что аудиофайлы существуют по указанным вами путям.
Каталог по умолчанию для FlameAudio — assets/audio (который можно изменить, предоставив свой собственный экземпляр AudioCache).
Для приведенных ниже примеров ваш файл pubspec.yaml должен содержать что-то вроде этого:
flutter:
assets:
- assets/audio/explosion.mp3
- assets/audio/music.mp3
Затем в вашем распоряжении есть следующие методы:
import 'package:flame_audio/flame_audio.dart';
// Для более коротких повторно используемых аудиоклипов, таких как звуковые эффекты
FlameAudio.play('explosion.mp3');
// Для циклического воспроизведения аудиофайла
FlameAudio.loop('music.mp3');
// Для воспроизведения более длинного аудиофайла
FlameAudio.playLongAudio('music.mp3');
// Для циклического воспроизведения более длинного аудиофайла
FlameAudio.loopLongAudio('music.mp3');
// Для фоновой музыки, которая должна быть приостановлена/воспроизведена при приостановке/возобновлении
// игры
FlameAudio.bgm.play('music.mp3');
Разница между play/loop и playLongAudio/loopLongAudio заключается в том, что play/loop использует оптимизированные функции, которые позволяют зацикливать звуки без пропусков между их итерациями, и почти не происходит падения частоты кадров игры. Вы должны, когда это возможно, отдавать предпочтение первым методам.
playLongAudio/loopLongAudio позволяет воспроизводить аудио любой длины, но они действительно создают падение частоты кадров, и зацикленное аудио будет иметь небольшой разрыв между итерациями.
Вы можете использовать класс Bgm (через FlameAudio.bgm) для воспроизведения зацикленных треков фоновой музыки. Класс Bgm позволяет Flame автоматически управлять приостановкой и возобновлением треков фоновой музыки, когда игра отправляется в фоновый режим или возвращается на передний план.
Вы можете использовать класс AudioPool, если хотите очень эффективно воспроизводить быстрые звуковые эффекты. AudioPool будет поддерживать пул AudioPlayers, предварительно загруженных заданным звуком, и позволит вам очень быстро воспроизводить их в быстрой последовательности.
Некоторые форматы файлов, которые работают на разных устройствах и которые мы рекомендуем: MP3, OGG и WAV.
Эта библиотека-мост (flame_audio) использует audioplayers, чтобы разрешить одновременное воспроизведение нескольких звуков (что имеет решающее значение в игре). Вы можете проверить ссылку для получения более подробного объяснения.
Как в play, так и в loop вы можете передать дополнительный необязательный параметр double — громкость (по умолчанию 1.0).
Оба метода play и loop возвращают экземпляр AudioPlayer из библиотеки audioplayers, который позволяет останавливать, приостанавливать и настраивать другие параметры.
Фактически, вы всегда можете использовать AudioPlayers напрямую, чтобы получить полный контроль над тем, как воспроизводится ваш звук — класс FlameAudio — это просто оболочка для общих функций.
Вы можете предварительно загрузить свои ресурсы. Аудио необходимо хранить в памяти при первом запросе; поэтому в первый раз, когда вы воспроизводите каждый mp3, может возникнуть задержка. Чтобы предварительно загрузить ваши аудио, просто используйте:
await FlameAudio.audioCache.load('explosion.mp3');
Вы можете загрузить все свои аудио в начале в методе onLoad вашей игры, чтобы они всегда воспроизводились плавно. Чтобы загрузить несколько аудиофайлов, используйте метод loadAll:
await FlameAudio.audioCache.loadAll(['explosion.mp3', 'music.mp3']);
Наконец, вы можете использовать метод clear, чтобы удалить файл, который был загружен в кэш:
FlameAudio.audioCache.clear('explosion.mp3');
Существует также метод clearCache, который очищает весь кэш.
Это может быть полезно, например, если в вашей игре есть несколько уровней, и у каждого из них есть свой набор звуков и музыки.