paint-brush
Разработка игр MVP с Flutter и Flameby@leobit
408
408

Разработка игр MVP с Flutter и Flame

Leobit28m2024/06/06
Read on Terminal Reader

MVP (минимально жизнеспособные продукты) становятся все более популярными в игровой индустрии. С MVP вы можете создать приложение с базовым функционалом в короткие сроки и при ограниченном бюджете. С помощью Flame, надежного игрового движка с открытым исходным кодом, созданного на основе Flutter, вы можете создавать потрясающие 2D-игры.
featured image - Разработка игр MVP с Flutter и Flame
Leobit HackerNoon profile picture
0-item

Согласно недавнему опросу , только 2 из 5 стартапов являются прибыльными. MVP (минимально жизнеспособный продукт) значительно увеличивает шансы стартапа на прибыльность, поскольку позволяет таким компаниям собирать ранние отзывы пользователей, не тратя весь бюджет на приложение с полной функциональностью.


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


MVP становятся все более популярными в игровой индустрии. Сегодня мы рассмотрим все тонкости быстрой разработки MVP игр с помощью Flutter и Flame, звездной комбинации для создания кроссплатформенных минимально жизнеспособных продуктов.

Почему стоит выбрать Flutter и Flame?

Flutter, многофункциональная и безопасная платформа для кроссплатформенной разработки , покорила мир мобильных приложений, и ее сфера действия выходит далеко за рамки пользовательского интерфейса. С помощью Flame, надежного игрового движка с открытым исходным кодом, созданного на основе Flutter , вы можете создавать потрясающие 2D-игры, которые без проблем работают на устройствах Android, iOS, в Интернете и на настольных компьютерах.


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


  • Создайте продукт с общей кодовой базой для разных платформ, включая Android и iOS, что намного быстрее и экономичнее, чем создание отдельных нативных приложений для разных платформ. Существуют также определенные методы создания веб-приложений Flutter с той же кодовой базой.


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


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


  • Разработка минимально жизнеспособного продукта обычно требует минимального количества ресурсов, и Flutter полностью удовлетворяет этому требованию, поскольку стандартная интеграция Flutter с Firebase значительно снижает сложность серверного программирования.


Flutter не потребляет много вычислительных ресурсов и упрощает настройку кроссплатформенных приложений.


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


Кроме того, обе технологии могут похвастаться активными сообществами с обширной документацией, учебными пособиями и примерами кода. Это означает, что вам никогда не придется искать ответ или вдохновение.

Так что же может сделать пламя?

Flame предоставляет целый набор инструментов для создания MVP-функций игры в короткие сроки и без перерасхода ресурсов. Эта кросс-платформенная среда моделирования предлагает инструменты для широкого спектра различных вариантов использования:


  • Спрайты и анимация. Вы можете быстро создавать свои спрайты или использовать их из различных онлайн-библиотек. Flame также поддерживает скелетную анимацию, что позволяет создавать более сложные и реалистичные анимации.


  • Обнаружение столкновений. Имеет встроенную систему обнаружения столкновений, которая позволяет легко создавать игры с собственной физикой. Вы можете использовать обнаружение столкновений для создания платформ, стен, предметов коллекционирования и других вещей, с которыми ваши игровые персонажи могут взаимодействовать.


  • Физические симуляции: Flame также поддерживает физические симуляции, которые позволяют создавать более динамичные и увлекательные игровые механики. Вы можете использовать физическое моделирование для создания таких вещей, как гравитация, прыжки и подпрыгивания.


  • Звук и звуковые эффекты. Вы можете использовать звук для создания фоновой музыки, звуковых эффектов (например, удара, прыжка и т. д.) и даже озвучки.


  • Управление состоянием: Flame предоставляет ряд функций для управления состоянием вашей игры. Сюда входят такие вещи, как ведение счета, управление уровнями и данные игроков.


  • Устройства ввода: Flame поддерживает различные устройства ввода, такие как сенсорные экраны, клавиатуры и игровые контроллеры. Это делает его отличным вариантом для разработки игр для различных платформ.


  • Параллаксная прокрутка: поддерживает параллаксную прокрутку, которая может добавить глубины и погружения в ваш игровой мир. Параллаксная прокрутка создает иллюзию глубины за счет перемещения разных слоев фона с разной скоростью.


  • Системы частиц: Flame также поддерживает системы частиц, которые можно использовать для создания различных визуальных эффектов, таких как взрывы, дым и дождь.


  • Многопользовательский игровой процесс: позволяет игрокам соревноваться или сотрудничать друг с другом в режиме реального времени.


Большинство из вышеупомянутых функций необходимы для многих игр, и их не следует упускать из виду даже на этапе разработки MVP. Что действительно важно, так это то, что Flame значительно повышает скорость разработки упомянутого выше функционала, позволяя вам выпускать такие функции даже в самых ранних версиях продукта.

Давай попробуем

Теперь, вместо того, чтобы говорить о Flame, давайте создадим MVP, содержащий основные функции нашей собственной игры с помощью этого фреймворка. Прежде чем мы начнем, у вас должен быть установлен Flutter 3.13 или новее, ваша любимая IDE и устройство для тестирования.

Идея

Эта игра вдохновлена Chrome Dino. Ах, знаменитый Дино-забег! Это больше, чем просто игра от Chrome. Это всеми любимая пасхалка, спрятанная в автономном режиме браузера.


Наш проект будет иметь следующий геймплей:

  • Вы играете за Джека, отважного парня, который бесконечно бежит по темному лесу.
  • Управление минимальное: нажимайте пробел или кликайте по экрану, чтобы прыгать.
  • Игра начинается медленно, но постепенно набирает скорость, держа вас в напряжении.
  • Ваша цель проста: избегать препятствий и бежать как можно дальше, набирая по пути очки.


И он будет называться «Лесной забег!»

Приготовься

Создайте пустой проект Flutter, как вы делаете каждый раз, когда запускаете новое приложение. Для начала нам нужно установить зависимости в pubspec.yaml для нашего проекта. На момент написания этого поста последняя версия Flame — 1.14.0. Кроме того, давайте сейчас определим все пути к ресурсам, чтобы не было необходимости возвращаться к этому файлу позже. И поместите изображения в каталог assets/images/. Нам нужно поместить его сюда, потому что Flame будет сканировать именно этот путь:


 environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/


Не забудьте поместить все изображения в папку assets/images/, потому что Flame не будет анализировать другие каталоги.


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



Вы можете посетить эти ссылки или просто загрузить подготовленные ресурсы (ССЫЛКА НА АРХИВ АКТИВОВ) для этого проекта и скопировать весь контент в свой проект.


У Flame схожая философия с Flutter. Во Flutter все является виджетами; в Flame всё является Компонентами, даже вся Игра. Каждый компонент может переопределить два метода: onLoad() и update(). onLoad() вызывается только один раз, когда компонент монтируется в ComponentTree, а update() запускается в каждом кадре. Очень похоже на initState() и build() из StatefulWidget во Flutter.


Теперь давайте напишем код. Создайте класс, который расширяет FlameGame и загружает все наши ресурсы в кеш.


 class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }


Затем используйте ForestRunGame в main.dart. Кроме того, вы можете использовать методы Flame.device для настройки ориентации устройства. И есть GameWidget, который служит мостом между виджетами и компонентами.


 Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }


На этом этапе мы уже можем запустить игру, но будет только черный экран. Итак, нам нужно добавить наши компоненты.

Темный лес

Мы разделим лес на две составляющие: фон и передний план. Во-первых, мы займемся фоном. Вы когда-нибудь прокручивали страницу, которая казалась динамичной? Как если бы вы прокручивали несколько представлений одновременно? Это эффект параллакса, и он возникает, когда разные элементы страницы движутся с разной скоростью, создавая эффект трехмерной глубины.


Как вы можете догадаться, в качестве фона мы будем использовать параллакс. Расширьте ParallaxComponent и настройте стек изображений, используя ParallaxImageData. Кроме того, существует baseVelocity для скорости начальных слоев и скоростьMultiplierDelta, которая обозначает относительную разницу в скорости между слоями. И последнее: настройте поле приоритета (z-index), чтобы оно переместилось позади других компонентов.


 class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }


Фон готов; теперь пришло время добавить передний план. Расширьте PositionComponent, чтобы мы могли выровнять землю по нижней части экрана. Нам также понадобится миксин HasGameReference для доступа к игровому кешу.


Чтобы создать землю, вам просто нужно несколько раз поместить изображение блока земли в одну линию. В Flame компоненты изображения называются спрайтами. Спрайт — это область изображения, которую можно отобразить на холсте. Он может представлять собой все изображение или быть одной из частей листа спрайтов.


Также помните, что ось X ориентирована вправо, а ось Y — вниз. Центр осей расположен в левом верхнем углу экрана.



 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }


И последнее, добавьте эти компоненты в нашу ForestRunGame.


 class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }


Теперь попробуйте запустить игру. На данный момент у нас уже есть наш лес.


Незнакомец в лесу

Лес выглядит хорошо, но на данный момент это всего лишь картинка. Итак, мы собираемся создать Джека, который будет бегать по этому лесу под руководством игрока. В отличие от деревьев и земли, игроку нужна анимация, чтобы чувствовать себя живым. Мы использовали Sprite для наземных блоков, но собираемся использовать SpriteAnimation для Джека. Как это работает? Ну, все просто, вам просто нужно зациклить последовательность спрайтов. Например, наша анимация бега имеет 8 спрайтов, которые сменяют друг друга с небольшим промежутком во времени.



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


 enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }


Состояние игрока готово. Теперь нам нужно указать игроку размер и положение на экране. Я собираюсь установить его размер 69x102 пикселей, но вы можете изменить его по своему усмотрению. Для определения местоположения мы должны знать координаты земли. Добавив миксин HasGameReference, мы можем получить доступ к полю переднего плана и получить его координаты. Теперь давайте переопределим метод onGameResize, который вызывается каждый раз при изменении размера приложения, и установим там положение Джека.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }


Как было сделано ранее, добавляем игрока в нашу игру.


 class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }


Если вы запустите игру, то увидите, что Джек уже в лесу!


Беги, Джек, беги!

Наша игра имеет три состояния: вступление, игра и окончание игры. Итак, мы добавим перечисление GameState, которое представляет их. Чтобы заставить Джека бежать, нам нужны переменные скорости и ускорения. Также нам необходимо рассчитать пройденное расстояние (будет использовано позже).


Как упоминалось ранее, Компонент имеет два основных метода: onLoad() и update(). Мы уже несколько раз использовали метод onLoad. Теперь давайте поговорим об update(). Этот метод имеет один параметр, называемый dt. Он представляет собой время, прошедшее с момента последнего вызова update().


Чтобы рассчитать текущую скорость и пройденное расстояние, мы воспользуемся методом update() и некоторыми базовыми формулами кинематики:

  • Расстояние = скорость * время;
  • Скорость = ускорение * время;


 enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }


На самом деле, чтобы упростить разработку, мы воспользуемся хитростью: Джек будет стоять устойчиво, но лес будет двигаться навстречу Джеку. Итак, нам нужен наш лес, чтобы применить скорость игры.


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


 class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }


Для переднего плана нам нужно сдвинуть каждый наземный блок. Также нам нужно проверить, покинул ли экран первый блок в очереди. Если да, то удалите его и поместите в конец очереди;


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }


Все готово, кроме триггера. Мы хотим начать работу по клику. Нашими целями являются как мобильные, так и настольные компьютеры, поэтому мы хотим обрабатывать нажатия на экран и события клавиатуры.


К счастью, у Flame есть способ сделать это. Просто добавьте миксин для вашего типа ввода. Для клавиатуры это KeyboardEvents и TapCallbacks для касания экрана. Эти миксины дают вам возможность переопределять связанные методы и обеспечивать свою логику.


Игра должна запуститься, если пользователь нажмет пробел или коснется экрана.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }


В результате Джек теперь может бежать после нажатия.


О нет, Буш!

Теперь мы хотим, чтобы на дороге были препятствия. В нашем случае они будут представлены ядовитыми кустами. Буш не анимирован, поэтому мы можем использовать SpriteComponent. Кроме того, нам нужна ссылка на игру, чтобы узнать ее скорость. И вот еще; мы не хотим спавнить кусты один за другим, потому что такой подход может привести к ситуации, когда Джек просто не сможет прыжком пройти линию кустов. Это случайное число из диапазона, зависящего от текущей скорости игры.


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }


Кто сажает кусты? Природа, конечно. Давайте создадим Природу, которая будет управлять нашим поколением кустов.


 class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }


Теперь давайте добавим природу в наш ForestForeground.


 class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }


Теперь в нашем лесу есть кусты. Но подождите, Джек просто пробегает мимо них. Почему это происходит? Это потому, что мы еще не реализовали удары.


Здесь нам поможет Hitbox. Hitbox — еще один компонент в зоопарке компонентов Flame. Он инкапсулирует обнаружение столкновений и дает вам возможность обрабатывать его с помощью специальной логики.


Добавьте один для Джека. Помните, что позиция компонента будет располагать его левый-правый угол, а не центр. А с размером вы справитесь со всем остальным.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }


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


 class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }


Это круто, но я не вижу, правильно ли было отрегулировано положение хитбокса. Как я могу это проверить?


Ну, вы можете установить для поля debugMode Player и Bush значение true. Это позволит вам увидеть, как расположены ваши хитбоксы. Фиолетовый определяет размер компонента, а желтый указывает на хитбокс.


Теперь мы хотим определить, когда происходит столкновение между игроком и кустом. Для этого вам нужно добавить миксин HasCollisionDetection в Game, а затем CollisionCallbacks для компонентов, которым необходимо обрабатывать столкновения.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }


А пока просто приостановите игру при обнаружении столкновения.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }


Прыгай или умри

Если Джек хочет избежать этих кустов, ему нужно прыгнуть. Давайте научим его. Для этой функции нам нужна постоянная гравитации и начальная вертикальная скорость прыжка Джека. Эти значения были выбраны на глаз, поэтому не стесняйтесь их корректировать.


Итак, как работает гравитация? По сути, это то же ускорение, но ориентированное на землю. Итак, мы можем использовать те же формулы для вертикального положения и скорости. Итак, наш прыжок будет состоять из 3 шагов:

  1. Срабатывает прыжок, и вертикальная скорость Джека меняется от нуля до начального значения.
  2. Он движется вверх, и гравитация постепенно меняет его скорость. В какой-то момент Джек перестанет двигаться вверх и начнет двигаться вниз.
  3. Когда Джек коснется земли, нам нужно прекратить воздействие на него гравитации и вернуть его в состояние бега.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }


А теперь давайте активируем прыжки по клику из ForestRunGame


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }


Теперь Джек может справиться с кустами.

Игра закончена

Когда игра закончится, мы хотим показать текст на экране. Текст в Flame работает иначе, чем во Flutter. Сначала вам нужно создать шрифт. По сути, это просто карта, где char — это ключ, а спрайт — значение. Почти всегда шрифт игры представляет собой одно изображение, в котором собраны все необходимые символы.


Для этой игры нам нужны только цифры и заглавные буквы. Итак, давайте создадим наш шрифт. Для этого вам необходимо передать исходное изображение и глифы. Что такое глиф? Глиф — это объединение информации о символе, его размере и положении в исходном изображении.


 class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }


Теперь мы можем создать панель Game Over и использовать ее в игре.


 class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }


Теперь мы можем показать нашу панель, когда Джек ударяется о кусты. Также давайте изменим метод start(), чтобы мы могли перезапускать игру по клику. Также нам нужно очистить лес от всех кустов.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }


И теперь нам нужно обновить обратный вызов столкновений в плеере.


 class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }


Теперь вы можете увидеть, как игра окончена, когда Джек ударяется о куст. И перезапустите игру, просто нажав еще раз.


А что насчет моего результата?

И последний подсчет очков.


 class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }


Вот и все, ребята!


А теперь попробуйте и попытайтесь побить мой рекорд. Это 2537 баллов!

Выводы

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


Также существует пакет Flame_audio, с помощью которого вы можете добавить фоновую музыку, звуки прыжков или ударов и т. д.


На данный момент нашей основной задачей было создание базовой функциональности продукта в короткие сроки и с ограниченным выделением ресурсов. Комбинация Flutter и Flame идеально подходит для создания MVP игры, которую можно использовать для сбора отзывов пользователей и дальнейшего обновления приложения в будущем.


Вы можете проверить результаты наших усилий здесь .


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


Мы надеемся, что эта статья оказалась для вас интересной и информативной. Если вам нужна дополнительная информация о разработке программного обеспечения или вы хотите обсудить свой собственный проект MVP, не стесняйтесь изучить Leobit или обратиться к нашей технической команде!