Обучение вашего персонажа бежать в огне
1 марта 2023 г.Я всегда хотел делать видеоигры. Моим первым Android-приложением, которое помогло мне получить первую работу, была простая игра, созданная с помощью представлений Android. После этого было много попыток создать более проработанную игру с использованием игрового движка, но все они потерпели неудачу из-за нехватки времени или сложности фреймворка. Но когда я впервые услышал о движке Flame, основанном на Flutter, меня сразу привлекла его простота и кроссплатформенность, поэтому я решил попробовать создать на нем игру.
Я хотел начать с чего-то простого, но все же сложного, чтобы почувствовать двигатель. Эта серия статей — мой путь изучения Flame (и Flutter) и создания базовой игры-платформера. Я постараюсь сделать его достаточно подробным, так что он будет полезен всем, кто только приобщается к Flame или геймдеву в целом.
В течение 4 статей я собираюсь построить 2D-игру с боковой прокруткой, которая включает в себя:
- Персонаж, который может бегать и прыгать
- Камера, которая следует за игроком
- Карта уровня с прокруткой, с землей и платформами
- Фон параллакса
- Монеты, которые игрок может собрать, и HUD, отображающий количество монет.
- Экран Win
В первой части мы создадим новый проект Flame, загрузим все ресурсы, добавим персонажа игрока и научим его бегать.
Настройка проекта
Сначала создадим новый проект. Официальное руководство по игре Bare Flame прекрасно описывает все шаги. чтобы сделать это, так что просто следуйте ему.
:::подсказка
Еще одно добавление: когда вы настраиваете файл pubspec.yaml
, вы можете обновить версии библиотек до последней доступной или оставить все как есть, потому что знак вставки (^) перед версией убедитесь, что ваше приложение использует последнюю некритическую версию. (синтаксис знака вставки)
:::
Если вы выполнили все шаги, ваш файл main.dart
должен выглядеть так:
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
Активы
Прежде чем мы продолжим, нам нужно подготовить ресурсы, которые будут использоваться в игре. Ресурсы – это изображения, анимация, звуки и т. д. В этой серии мы будем использовать только изображения, которые также называются спрайтами в разработке игр.
Самый простой способ построить уровень платформера — использовать тайловые карты и тайловые спрайты. Это означает, что уровень в основном представляет собой сетку, где каждая ячейка указывает, какой объект/землю/платформу она представляет. Позже, когда игра запущена, информация из каждой ячейки сопоставляется с соответствующим спрайтом плитки.
Графика игр, созданная с использованием этой техники, может быть очень сложной или очень простой. Например, в Super Mario bros вы видите, что многие элементы повторяются. Это потому, что для каждой плитки земли в игровой сетке есть только одно изображение земли, которое ее представляет. Мы будем следовать тому же подходу и подготовим отдельное изображение для каждого статического объекта, который у нас есть.
Мы также хотим, чтобы некоторые объекты, такие как персонаж игрока и монеты, были анимированы. Анимация обычно хранится в виде серии неподвижных изображений, каждое из которых представляет собой отдельный кадр. При воспроизведении анимации кадры идут один за другим, создавая иллюзию движения объекта.
Теперь самый главный вопрос – где взять активы. Конечно, вы можете нарисовать их сами или заказать у художника. Кроме того, есть много замечательных художников, которые предоставили игровые ресурсы для открытого исходного кода. Я буду использовать набор ресурсов Arcade Platformer Assets от GrafxKid.
Как правило, ресурсы изображений бывают двух видов: листы спрайтов и отдельные спрайты. Первый представляет собой большое изображение, содержащее все игровые активы в одном. Затем разработчики игры указывают точное положение нужного спрайта, и игровой движок вырезает его из листа. Для этой игры я буду использовать отдельные спрайты (кроме анимаций, их проще сохранить как одно изображение), потому что мне не нужны все активы, представленные на листе спрайтов.
Независимо от того, создаете ли вы спрайты самостоятельно или получаете их от художника, вам может понадобиться нарезать их, чтобы сделать их более подходящими для игрового движка. Вы можете использовать инструменты, созданные специально для этой цели (например, упаковщик текстур) или любой графический редактор. Я использовал Adobe Photoshop, потому что в этом листе спрайтов спрайты имеют неравное расстояние между ними, из-за чего автоматическим инструментам было сложно извлекать изображения, поэтому мне пришлось делать это вручную.
:::подсказка
Вы также можете увеличить размер ресурсов, но если это не векторное изображение, результирующий спрайт может стать размытым. Я обнаружил, что один обходной путь, который отлично подходит для пиксельной графики, — это использовать метод изменения размера Nearest Neighbor (жесткие края)
в Photoshop (или Interpolation, установленный на None в Gimp). Но если ваш ресурс более детализирован, это, вероятно, не сработает.
:::
Без пояснений загрузите подготовленные мной ресурсы или подготовьте свои собственные и добавьте их в Папка assets/images
вашего проекта.
Всякий раз, когда вы добавляете новые ресурсы, вам необходимо зарегистрировать их в файле pubspec.yaml
следующим образом:
flutter:
assets:
- assets/images/
:::подсказка И совет на будущее: если вы обновляете уже зарегистрированные активы, вам нужно перезапустить игру, чтобы увидеть изменения.
:::
Теперь давайте фактически загрузим активы в игру. Мне нравится хранить все имена ассетов в одном месте, что отлично подходит для небольшой игры, так как легче отслеживать все и изменять при необходимости. Итак, давайте создадим новый файл в каталоге lib
: assets.dart
const String THE_BOY = "theboy.png";
const String GROUND = "ground.png";
const String PLATFORM = "platform.png";
const String MIST = "mist.png";
const String CLOUDS = "clouds.png";
const String HILLS = "hills.png";
const String COIN = "coin.png";
const String HUD = "hud.png";
const List<String> SPRITES = [THE_BOY, GROUND, PLATFORM, MIST, CLOUDS, HILLS, COIN, HUD];
А затем создайте еще один файл, в котором в дальнейшем будет храниться вся игровая логика: game.dart
import 'package:flame/game.dart';
import 'assets.dart' as Assets;
class PlatformerGame extends FlameGame {
@override
Future<void> onLoad() async {
await images.loadAll(Assets.SPRITES);
}
}
PlatformerGame
— это основной класс, представляющий нашу игру, он расширяет FlameGame
, базовый класс игры, используемый в движке Flame. Что, в свою очередь, расширяет Component
— основной строительный блок Flame. Все в вашей игре, включая изображения, интерфейс или эффекты, является Компонентами. Каждый Component
имеет асинхронный метод onLoad
, который вызывается при инициализации компонента. Обычно вся логика настройки компонента находится там.
Наконец, мы импортировали наш файл assets.dart
, который мы создали ранее, и добавили as Assets
, чтобы явно указать, откуда берутся наши константы ресурсов. И использовал метод images.loadAll
для загрузки всех активов, перечисленных в списке SPRITES
, в кеш изображений игры.
Затем нам нужно создать нашу новую PlatformerGame
из main.dart
. Измените файл следующим образом:
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'game.dart';
void main() {
runApp(
const GameWidget<PlatformerGame>.controlled(
gameFactory: PlatformerGame.new,
),
);
}
Все приготовления завершены, и начинается самое интересное.
Добавление персонажа
Создайте новую папку lib/actors/
и в ней новый файл theboy.dart
. Это будет компонент, представляющий персонажа игрока: Мальчика.
import '../game.dart';
import '../assets.dart' as Assets;
import 'package:flame/components.dart';
class TheBoy extends SpriteAnimationComponent with HasGameRef<PlatformerGame> {
TheBoy({
required super.position, // Position on the screen
}) : super(
size: Vector2.all(48), // Size of the component
anchor: Anchor.bottomCenter //
);
@override
Future<void> onLoad() async {
animation = SpriteAnimation.fromFrameData(
game.images.fromCache(Assets.THE_BOY),
SpriteAnimationData.sequenced(
amount: 1, // For now we only need idle animation, so we load only 1 frame
textureSize: Vector2.all(20), // Size of a single sprite in the sprite sheet
stepTime: 0.12, // Time between frames, since it's a single frame not that important
),
);
}
}
Класс расширяет SpriteAnimationComponent
, который является компонентом, используемым для анимированных спрайтов, и имеет миксин HasGameRef
, который позволяет нам ссылаться на игровой объект для загрузки изображений из кеша игры или получить глобальные переменные позже.
В нашем методе onLoad
мы создаем новый SpriteAnimation
из листа спрайтов THE_BOY
, который мы объявили в файле assets.dart
. .
Теперь давайте добавим нашего игрока в игру! Вернитесь к файлу game.dart
и добавьте следующее в конец метода onLoad
:
final theBoy = TheBoy(position: Vector2(size.x / 2, size.y / 2));
add(theBoy);
Если вы сейчас запустите игру, мы сможем встретиться с Мальчиком!
Движение игрока
Во-первых, нам нужно добавить возможность управлять Мальчиком с клавиатуры. Давайте добавим миксин HasKeyboardHandlerComponents
в файл game.dart
.
class PlatformerGame extends FlameGame with HasKeyboardHandlerComponents
Теперь вернемся к миксину theboy.dart
и KeyboardHandler
:
class TheBoy extends SpriteAnimationComponent with KeyboardHandler, HasGameRef<PlatformerGame>
Затем добавьте несколько новых переменных класса в компонент TheBoy
:
final double _moveSpeed = 300; // Max player's move speed
int _horizontalDirection = 0; // Current direction the player is facing
final Vector2 _velocity = Vector2.zero(); // Current player's speed
Наконец, давайте переопределим метод onKeyEvent
, который позволяет прослушивать ввод с клавиатуры:
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_horizontalDirection = 0;
_horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyA) ||
keysPressed.contains(LogicalKeyboardKey.arrowLeft))
? -1
: 0;
_horizontalDirection += (keysPressed.contains(LogicalKeyboardKey.keyD) ||
keysPressed.contains(LogicalKeyboardKey.arrowRight))
? 1
: 0;
return true;
}
Теперь _horizontalDirection
равно 1, если игрок движется вправо, -1, если игрок движется влево, и 0, если игрок не движется. Однако мы пока не можем увидеть его на экране, потому что положение игрока еще не изменилось. Давайте исправим это, добавив метод update
.
Теперь мне нужно объяснить, что такое игровой цикл. По сути, это означает, что игра запускается в бесконечном цикле. На каждой итерации текущее состояние отображается в методе render
Component
, а затем вычисляется новое состояние в методе update
. Параметр dt
в сигнатуре метода — это время в миллисекундах с момента последнего обновления состояния. Имея это в виду, добавьте в theboy.dart
следующее:
@override
void update(double dt) {
super.update(dt);
_velocity.x = _horizontalDirection * _moveSpeed;
position += _velocity * dt;
}
Для каждого цикла игрового цикла мы обновляем горизонтальную скорость, используя текущее направление и максимальную скорость. Затем мы меняем позицию спрайта на обновленное значение, умноженное на dt
.
Зачем нужна последняя часть? Ну а если обновить позицию только скоростью, то спрайт улетит в космос. Но можем ли мы просто использовать меньшее значение скорости, спросите вы? Можем, но то, как движется игрок, будет отличаться в зависимости от частоты кадров в секунду (FPS). Количество кадров (или игровых циклов) в секунду зависит от производительности игры и оборудования, на котором она запущена. Чем лучше производительность устройства, тем выше FPS и тем быстрее движется игрок. Чтобы этого избежать, мы делаем скорость зависимой от времени, прошедшего с последнего кадра. Таким образом, спрайт будет двигаться одинаково при любом FPS.
Хорошо, если мы сейчас запустим игру, мы должны увидеть это:
Отлично, теперь давайте заставим мальчика поворачиваться, когда он идет налево. Добавьте это в конец метода update
:
if ((_horizontalDirection < 0 && scale.x > 0) || (_horizontalDirection > 0 && scale.x < 0)) {
flipHorizontally();
}
Довольно простая логика: мы проверяем, отличается ли текущее направление (стрелка, которую нажимает пользователь) от направления спрайта, затем переворачиваем спрайт по горизонтальной оси.
Теперь давайте также добавим анимацию бега. Сначала определите две новые переменные класса:
late final SpriteAnimation _runAnimation;
late final SpriteAnimation _idleAnimation;
Затем обновите onLoad
следующим образом:
@override
Future<void> onLoad() async {
_idleAnimation = SpriteAnimation.fromFrameData(
game.images.fromCache(Assets.THE_BOY),
SpriteAnimationData.sequenced(
amount: 1,
textureSize: Vector2.all(20),
stepTime: 0.12,
),
);
_runAnimation = SpriteAnimation.fromFrameData(
game.images.fromCache(Assets.THE_BOY),
SpriteAnimationData.sequenced(
amount: 4,
textureSize: Vector2.all(20),
stepTime: 0.12,
),
);
animation = _idleAnimation;
}
Здесь мы извлекли ранее добавленную анимацию бездействия в переменную класса и определили новую переменную анимации запуска.
Затем добавим новый метод updateAnimation
:
void updateAnimation() {
if (_horizontalDirection == 0) {
animation = _idleAnimation;
} else {
animation = _runAnimation;
}
}
И, наконец, вызовите этот метод в нижней части метода update
и запустите игру.
Заключение
Это первая часть. Мы узнали, как настроить игру Flame, где найти ресурсы, как загрузить их в игру и как создать потрясающего анимированного персонажа и заставить его двигаться в зависимости от ввода с клавиатуры. Код этой части можно найти на моем github.
В следующей статье я расскажу, как создать игровой уровень с помощью Tiled, как управлять камерой Flame и добавить фон параллакса. Оставайтесь с нами!
Ресурсы
В конце каждой части я добавлю список замечательных авторов и ресурсов, у которых я многому научился.
- Активы платформера GrafxKid Arcade https://opengameart.org/content/arcade-platformer-assets ли>
- Серия разработчиков игр DevKage Flame: https://youtu.be/mSPalRqZQS8
- Канал Крейга Оды https://youtu.be/hwQpBuZoV9s
- Учебное пособие по игре Ember Quest https://github.com/flame -engine/flame/blob/main/doc/tutorials/platformer/platformer.md
- Документация по Flame Engine https://docs.flame-engine.org/1.6.0/ пламя/пламя.html
Оригинал