Обложка статьи «Создание кроссплатформенной игры на Flutter за неделю»

Создание кроссплатформенной игры на Flutter за неделю

Перевод статьи «From Zero to a Multiplatform Flutter Game in a week»

Часто мобильные разработчики не хотят браться за создание игр, потому что это кажется долгим и трудоёмким процессом. Это работа с памятью и спрайтами, сложные UI/UX и много оптимизации. И если вы уже разрабатывали приложения под Android, то наверняка представляете, как было бы трудно создавать игры на нативном коде. Именно по этой причине стоить задуматься о разработке на Flutter — SDK от Google для создания кроссплатформенных приложений.

На GitHub есть пример игры Spaceblast, созданной на этом SDK.

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

Разработка игры

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

GestureDetector(
 onTapDown: (TapDownDetails details) => damage(details),
)
var _bossDamage = 980;

void damage(TapDownDetails details) {
 setState(() {
   _bossDamage = _bossDamage - 30 <= 0 ? 980 : _bossDamage - 30;
 });
}

Вот что должно получиться после добавления визуальных элементов:

Следующим шагом будет добавление UI-элементов, которые сделают игру более приятной. Потребуются спрайты персонажа, боссов (которым будет наноситься урон), бонусов и монет.

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

String hero() {
 return tap ? "assets/character/attack.png" : "assets/character/idle.png";
}

Для боссов же понадобится больше изображений. Это сделает игру более разнообразной и интересной.

static List getBosses() {
 var list = List();
 list.add(Bosses("Lunabi", 450, "assets/boss/boss_one.png"));
 list.add(Bosses("ivygrass", 880, "assets/boss/boss_two.png"));
 list.add(Bosses("Tombster", 1120, "assets/boss/boss_three.png"));
 list.add(Bosses("Glidestone", 2260, "assets/boss/boss_four.png"));
 list.add(Bosses("Smocka", 2900, "assets/boss/boss_five.png"));
 list.add(Bosses("Clowntorch", 4100, "assets/boss/boss_six.png"));
 list.add(Bosses("Marsattack", 5380, "assets/boss/boss_seven.png"));
 list.add(Bosses("Unknown", 7000, "assets/boss/boss_eight.png"));
 list.add(Bosses("ExArthur", 10000, "assets/boss/boss_nine.png"));
 return list;
}

У каждого босса есть имя, показатель жизни и спрайт.

Для бонусов код аналогичный:

static List getPowerUps() {
 var list = List();
 list.add(PowerUps("Master Sword", 2.15, false, 50));
 list.add(PowerUps("Lengendary Sword", 2.45, false, 180));
 list.add(PowerUps("Keyblade", 3.75, false, 300));
 list.add(PowerUps("Lightsaber", 4.95, false, 520));
 list.add(PowerUps("Buster Sword", 6.15, false, 1700));
 list.add(PowerUps("Soul Edge", 8.65, false, 2400));
 return list;
}

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

После этого можно добавить визуальный «хитбокс», который будет отображаться при каждом нажатии на экран:

Widget hitBox() {
 if (tap) {
   return Positioned(
     top: yAxis,
     left: xAxis,
     child: Column(
       children: [
         Padding(
           padding: const EdgeInsets.only(bottom: 20.0),
           child: Material(
             color: Colors.transparent,
             child: StrokeText(
               "-${damageUser.toInt().toString()}",
               fontSize: 14.0,
               fontFamily: "Gameplay",
               color: Colors.red,
               strokeColor: Colors.black,
               strokeWidth: 1.0,
             ),
           ),
         ),
         Image.asset(
           "assets/elements/hit.png",
           fit: BoxFit.fill,
           height: 80.0,
           width: 80.0,
         ),
       ],
     ),
   );
 } else {
   return Container();
 }
}

Этот код добавляет визуальный элемент на позицию прикосновения пальца. Так же он добавляет небольшую подсказку о количестве нанесённого урона (с некоторым смещением вверх).

При прикосновении используется параметр TapDownDetails для получения X и Y координат пальца.

Ещё нужно добавить логику скрытия этих элементов после того, как игрок отведёт палец от экрана.

GestureDetector(
 onTapDown: (TapDownDetails details) => damage(details),
 onTapUp: (TapUpDetails details) => hide(null),
 onTapCancel: () => hide(null),
),

В добавлении списка бонусов тоже нет ничего сложного:

ListView.builder(
 padding: EdgeInsets.only(bottom: 20.0, left: 10.0, right: 10.0),
 itemCount: list.length,
 itemBuilder: (context, position) {
   PowerUps powerUp = list[position];
   int bgColor = !powerUp.bought && coins >= powerUp.coins
       ? 0xFF808080
       : !powerUp.bought ? 0xFF505050 : 0xFF202020;

   return swordElement(bgColor, powerUp, position);
 },
)

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

Widget swordElement(int bgColor, PowerUps powerUp, int position) {
 return Padding(
   padding: const EdgeInsets.symmetric(
     vertical: 5.0,
   ),
   child: Container(
     height: 70,
     child: Card(
       color: Color(bgColor),
       child: Row(
         children: [
           Expanded(
             child: Padding(
               padding: const EdgeInsets.symmetric(horizontal: 20.0),
               child: Text(
                 powerUp.name,
                 style: Utils.textStyle(11.0),
               ),
             ),
           ),
           Padding(
             padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0),
             child: FancyButton(
               size: 20,
               child: Row(
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 10.0, bottom: 2, top: 2),
                     child: Text(
                       !powerUp.bought ? "BUY" : "BOUGHT",
                       style:
                       Utils.textStyle(13.0, color: !powerUp.bought ? Colors.white : Colors.grey),
                     ),
                   ),
                   Padding(
                     padding: EdgeInsets.only(left: 8.0, right: !powerUp.bought ? 2.0 : 0.0),
                     child: Text(
                       !powerUp.bought ? powerUp.coins.toString() : "",
                       style: Utils.textStyle(13.0),
                     ),
                   ),
                   coinVisibility(powerUp.bought),
                 ],
               ),
               color: !powerUp.bought && coins >= powerUp.coins
                   ? Colors.deepPurpleAccent
                   : Colors.deepPurple,
               onPressed: !powerUp.bought && coins >= powerUp.coins ? () => buyPowerUp(position) : null,
             ),
           )
         ],
       ),
     ),
   ),
 );
}

На текущем моменте игра выглядит так:

Звуки и музыка

Звуки играют очень важную роль в играх. Добавим их:

AudioPlayer hitPlayer;
AudioCache hitCache;
hitPlayer = AudioPlayer();
hitCache = AudioCache(fixedPlayer: hitPlayer);
// Если аудио ещё проигрывается, то его нужно остановить и проиграть заново
hitPlayer.pause();
hitCache.play('audio/sword.mp3');

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

AudioCache musicCache;
AudioPlayer instance;
void playMusic() async {
 musicCache = AudioCache(prefix: "audio/");
 instance = await musicCache.loop("bgmusic.mp3");
}

Звук будет проигрываться из InitState и будет остановлен из метода Dispose. Если присмотреться, то в игре можно увидеть кастомные шрифты — они не обязательны, но делают игру более ламповой.

Экран приветствия

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

Тут экран приветствия был создан из спрайта босса, игрока и фонового изображения с логотипом.

Ещё в экран приветствия был добавлен эффект Blur (размытие) и немного частиц огня и пепла. Код эффекта выглядит так:

BackdropFilter(
 filter: ImageFilter.blur(
   sigmaX: 4.0,
   sigmaY: 4.0,
 ),
 child: Align(
   alignment: Alignment.bottomCenter,
   child: Align(
     alignment: Alignment.topCenter,
     heightFactor: heroYAxis,
     child: Image.asset(
       heroAsset(),
       width: size / 1.5,
       height: size / 1.5,
       fit: BoxFit.cover,
     ),
   ),
 ),
)

Код частиц можно найти в этом репозитории.

Вот так выглядит итоговый результат:

Добавление механик в игру

По желанию можно усложнить игру, сделав её разнообразнее и интереснее. Например, можно добавить ограничение по времени. Оставшееся время будет увеличиваться при каждом убийстве босса. Ещё можно дать возможность поделиться результатом в соц. сетях.

Для отсчёта времени используется обычный AnimationController. Он отсчитывает прошедшее время в миллисекундах, пока не достигнет 0. Если вам нужно форматировать время для строки, то используйте этот фрагмент кода:

String get timerString {
 Duration duration = controller.duration * controller.value;
 return '$ {(duration.inMinutes) .toString (). padLeft (2,' 0 ')}: $ {(duration.inSeconds% 60) .toString (). padLeft (2,' 0 ')}';
}

Если счётчик достигнет 0, то будет активироваться «Game over»:

controller.addStatusListener ((status) {
 if (status == AnimationStatus.dismissed) {
   setState (() {
     gameOver = true ;
   });
 }
});

Поддержка геймпада

Это экспериментальная функция, поэтому с ней может происходить всё, что угодно.

Пока можно попробовать варианты Android + Switch Pro Controller (PS4 и Xbox Controller остаются в стороне).

Для работы с геймпадом нужно использовать каналы. Вначале код для Android Kotlin:

var channel =  MethodChannel(flutterView, "gamepad")
channel.setMethodCallHandler { call, result ->
   when {
       call.method == "isGamepadConnected" -> {
         val ids = InputDevice.getDeviceIds()
         for (id in ids) {
           val device = InputDevice.getDevice(id)
           val sources = device.sources

           if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD) {
             result.success(true)
           }
         }
         result.success(false)
       }
       call.method == "getGamePadName" -> {
         val gamepadIds = InputDevice.getDeviceIds()
         for (id in gamepadIds) {
           val device = InputDevice.getDevice(id)
           val sources = device.sources

           if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD) {
             result.success(device.name)
           }
         }
       }
       else -> result.notImplemented()
   }
 }
}

Его можно разместить внутри класса MainActivity. Ещё вы можете реализовать интерфейс InputManager.InputDeviceListner для обнаружения подключения/отключения геймпада:

var inputManager = getSystemService(Context.INPUT_SERVICE) as InputManager
override fun onResume() {
 super.onResume()
 inputManager.registerInputDeviceListener(this, null)
}

override fun onPause() {
 super.onPause()
 inputManager.unregisterInputDeviceListener(this)
}

override fun onInputDeviceRemoved(deviceId: Int) {
 channel.invokeMethod("gamepadRemoved", true)
}

override fun onInputDeviceAdded(deviceId: Int) {
 channel.invokeMethod("gamepadName", InputDevice.getDevice(deviceId).name)
}

override fun onInputDeviceChanged(deviceId: Int) {
 channel.invokeMethod("gamepadName", InputDevice.getDevice(deviceId).name)
}

При каждом из этих событий Android будет отправлять данные в канал:

static const MethodChannel _channel = const MethodChannel('gamepad');

static Future get isGamePadConnected async {
 final bool isConnected = await _channel.invokeMethod('isGamepadConnected');
 return isConnected;
}

static Future get gamepadName async {
 final String name = await _channel.invokeMethod("getGamePadName");
 return name;
}

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

_channel.setMethodCallHandler((call) async {
 switch (call.method) {
   case "gamepadName":
     setState(() {
       _gamepadName = call.arguments;
     });
     break;
   case "gamepadRemoved":
     setState(() {
       _gamepadName = "Undefined";
     });
     break;
 }
});

Аргументы могут быть следующими:

Результат:

Запуск на других платформах

Наверное, главным преимущество Flutter является его кроссплатформенность. Игры на нём можно запускать на Android, iOS, ПК и даже в вебе.

iOS

Первым делом вам потребуется Mac с установленным на нём XCode. Только так вы сможете собрать билд игры.

Прим. перев. Есть также вариант использовать codemagic.io.

Если вы используете Android Studio, то после нажатия на RUN выберете iOS устройство. Для этого у вас должна быть включена опция «Open iOS Simulator».

Ещё вы можете запустить игру на определённом устройстве по его ID. Идентификатор устройства можно посмотреть во «flutter devices», а после выполнить следующее:

flutter run -d {device id}
// ID указывать без фигурных скобок

ПК

Для компьютеров сборка игры чуть сложнее из-за дополнительных шагов. Её можно проводить не только на Mac, но и на Windows или Linux.

Процесс сборки игры детально описан в этой статье. В терминале нужно выполнить следующую команду:

export ENABLE_FLUTTER_DESKTOP=true

После этого вы сможете клонировать официальный репозиторий Flutter для ПК:

git clone https://github.com/google/flutter-desktop-embedding.git

cd example

Теперь вы должны скопировать папки «Mac», «Windows» и «Linux» в папку вашего проекта.

После этого нужно внести небольшие изменения в код:

void _setTargetPlatformForDesktop() {
 TargetPlatform targetPlatform;
 if (Platform.isMacOS) {
   targetPlatform = TargetPlatform.iOS;
 } else if (Platform.isLinux || Platform.isWindows) {
   targetPlatform = TargetPlatform.android;
 }
 if (targetPlatform != null) {
   debugDefaultTargetPlatformOverride = targetPlatform;
 }
}

Этот метод нужно вызвать в main().

void main() {
 _setTargetPlatformForDesktop();
 runApp(TapHero());
}

Убедитесь, что вы импортировали эти два класса:

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show debugDefaultTargetPlatformOverride;

Примечание Если вам нужно заблокировать поворот экрана на телефоне, то добавьте этот код перед возвратом в методе сборки:

SystemChrome.setPreferredOrientations([
 DeviceOrientation.portraitUp,
]);

import 'package:flutter/services.dart';

И наконец игру можно запускать на ПК. Целевая платформа должна появится в списке устройств.

Веб

Да, браузеры тоже никто не отменял. О сборке игр на Flutter под веб-версию описано в этой статье.

Процесс идентичен сборке под ПК. Сначала вам нужно клонировать репозиторий (там есть пример «hello_world»):

git clone git@github.com:flutter/flutter_web.git

cd examples/hello_world

Убедитесь, что вы используете последнюю версию Flutter:

flutter upgrade

И ещё вам потребуется пакет для веб-разработки:

flutter packages pub global activate webdev

flutter packages upgrade

Именно благодаря ему вы сможете собирать игры и запускать их в браузере. После этого нужно указать путь к Dart и Webdev и изменить импортируемые библиотеки для flutter_web, например:

import 'package:flutter_web/material.dart';

После этого нужно перенести все ассеты игры в папку /web/assets. Под конец остаётся нажать на строку webdev serve над терминалом.

Вот и всё. Теперь открыть игру в браузере можно по ссылке http://localhost:8080/.

Получение фактического URL

Знаете ли вы что-то о хостинге на Firebase? С его помощью вы сможете разместить ваше приложение и получить его URL. Вам нужна услуга «хостинг» и подробный гайд по ней:

https://firebase.google.com/docs/hosting/quickstart

После этого вам нужно будет создать своё Flutter Web App:

webdev build

В вашем проекте вы увидите новые папки: «build» и «public». Скопируйте всё содержимое первой во вторую. Последним шагом будет деплой:

firebase deploy

Вот и всё! Теперь ваше приложение должно быть доступно по адресу .web.app вашего домена. В игру из примера можно поиграть тут: https://gametaphero.web.app/#/

Примечание В папке public есть файл index.html. Он следует той же логике, что и любая веб-страница: для сайта можно установить иконку, цвет строки поиска (для Chrome mobile), название приложения и т. д.

Хинт для программистов: если зарегистрируетесь на соревнования Huawei Honor Cup, бесплатно получите доступ к онлайн-школе для участников. Можно прокачаться по разным навыкам и выиграть призы в самом соревновании.

Перейти к регистрации