Pull to refresh

Никому не советую, но мы попробовали, или Интеграция игры в React Native с помощью Unity, Game Engine и Godot

Level of difficultyMedium
Reading time14 min
Views1.7K

Меня зовут Алексей Цуцоев, я разработчик мобильных приложений в KODE, и я хочу поделиться историей того, как мы внедряли игру в уже готовое React-Native приложение. Как выбирали технологию, с какими трудностями столкнулись и к каким выводам пришли. Прежде чем мы начнем, хотелось бы отметить несколько важных моментов:

  • Я не game-developer. Остальная команда также не имела опыта разработки игр. Я — просто мобильный разработчик, которому поставили задачу разработать и интегрировать игру в приложение.

  • У нас была выстроена инфраструктура CI/CD, которая может отличаться от вашей.

  • Я постарался сфокусироваться на процессе от аналитики и дизайна до интеграции и подготовки билда. Для одной библиотеки я отдельно подготовил инструкцию.

Начнем повествование с постановки задачи по внедрению игры, а закончим конкретными реализациями на различных технологиях.

Постановка задачи и преданалитика

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

Цель игры — поднять вовлеченность и напомнить о приложении. Насколько я знаю, цели мы достигли — продажи были подняты на 10%.

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

``Cпойлер
Дизайнер в продуктовой разработке мобильных приложений совсем не равен художнику в геймдеве. Основная проблема в том, что они не умели готовить ассеты, из которы я бы потом мог сделать текстуры, их PNG просто не получалось натянуть на игровые объекты.
``

Первый важный момент: предоставьте пример ассетов вашим дизайнерам.

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

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

На этом этапе необходимо максимально продумать, как пользователь будет играть. Склонировать core-gameplay Flappy Bird, или вашего аналога, скорее всего, не составит труда. Проблемы начнутся, когда придётся сделать шаг влево или вправо от изначальной механики: добавить ограничение по очкам, упростить сложность или что-либо ещё, что будет отличать вашу игру от изначальной. И тут проблема не в технической реализации — в целом, большинство вещей будет правиться парой строк кода. Сложность в том, чтобы сделать это весёлым.

Звучит весьма банально, но QA окажется как нельзя кстати — опыт тестировщиков в "а если пользователь сделает вот так?" будет невероятно полезен уже на этапе аналитики. Таким образом вы сможете покрыть максимальное количество кейсов. Забегая вперёд, мы с аналитиком упустили парочку моментов, которые пришлось дорабатывать после тестирования.

Второй момент: если кажется, что продумано всё — подумайте ещё.

Так или иначе, дизайн готов, какой бы то ни было гейм-дизайн — тоже. Пришло время выбрать, как будем делать саму игру.

Поиск технологии

Для начала я взял некоторое время на ресерч и определил круг технологий, которые я смогу применить. Всерьёз рассмотрел три варианта:

  • Сделать всё на React Native, Skia и React Native Reanimated

  • Сделать игру нативно на UIKit/SwiftUI под iOS и Compose под Android

  • Завести один из игровых движков: Unity, Godot, Unreal или React Native Game Engine + matter-js

Определившись с шорт-листом номинантов, я пошёл методом исключения.

Почему не React Native?

Причины просты: все мы любим React Native за удобство и скорость разработки, но ещё ни один разработчик мобильных приложений в этой вселенной не сказал: "я люблю React Native за его производительность". У меня были серьёзные опасения, что перфоманс на не флагманских девайсах будет неудовлетворительным. Кроме того, пришлось бы самостоятельно реализовывать механизмы спавна-удаления игровых объектов и коллизии. Посовещавшись с командой, мы решили, что делать на React Native нам не подходит.

Почему не нативно?

Это решение 100% работало бы быстрее, чем RN + Skia + RNR, даже на слабых девайсах, но проблема реализации спавна-удаления объектов и коллизии никуда не девается. Кроме того, при выборе нативной реализации астрологи объявляют месяц дублирования кода — количество игр увеличивается вдвое. Да, выбирая нативную реализацию, мы обрекаем себя на разработку и поддержку сразу двух игр: одну для iOS, вторую для Android. А это означает, что любую правку от менеджмента или баг сразу умножаем на два.

Остаются игровые движки

Unreal мы сразу отмели, поскольку экспертизы в С++ в команде не было. Почти сразу за ним отправился React Native Game Engine из-за недоверия к этой технологии — последний коммит по существу был оставлен летом 2020-го.

А вот дальше интереснее: остаются Unity и Godot. 

Плюсы Unity:

  • Популярная и понятная технология с миллионами туториалов

  • Есть несколько библиотек для интеграции в React Native

  • Условно-бесплатное распространение (рекомендую внимательно ознакомиться с правилами лицензирования перед релизом)

Плюсы Godot:

  • Полностью бесплатный

  • Легковеснее Unity (что нужно для простой игры в духе клон Flappy Bird)

  • Можно вести разработку на gdScript – питонообразный язык, который с нуля будет проще и быстрее освоить, чем C#, который необходим для Unity.

Но имелся один большой минус, перечёркивающий всё — для него существует единственная библиотека интеграции игры в React Native, и она не поддерживает Android. А нас это категорически не устраивает. Таким образом, выбор сам собой сузился до Unity. Наконец-то мы можем приступить к разработке.

Внедрение Unity

Про интеграцию Unity в React Native уже написано немало — и статей, и туториалов. Плюс библиотека, которую мы будем использовать (@azesmway/react-native-unity), имеет вполне приличную документацию. Но, как всегда, дьявол в деталях. Поэтому ниже — пошаговое описание процесса с указанием нюансов.

Установка Unity и создание проекта

  1. Заходим на официальный сайт Unity и скачиваем Unity Hub.

  2. Через него устанавливаем нужную версию движка. В моём случае это была Unity 6 (6000.0.36f1).

3. В Unity Hub создаём новый проект:

  • Жмём New Project

  • Выбираем шаблон 2D (Mobile)

  • Даём имя, указываем папку, запускаем

Игру писать с нуля мы не стали — взяли готовый туториал, адаптировали под свои нужды и заменили ассеты. Сам код геймплея сюда не включаю — это отдельная тема. Уверен, вы легко найдёте десятки руководств по Flappy Bird под Unity.

После окончания разработки игры необходимо экспортировать исходники под каждую платформу в React-Native. Начнем с подготовки самого проекта для этой интеграции.

Теперь нам понадобится библиотека @azesmway/react-native-unity. Она поможет интегрировать игру в наше React-Native приложение.

4. Устанавливаем зависимость по инструкции. 

yarn add @azesmway/react-native-unity && npx pod-nstall

Могут возникнуть проблемы с кешами. После выгрузки новых iOS-исходников имеет смысл очищать поды, но об этом позже. После установки зависимости необходимо экспортировать исходники под каждую платформу. Начнем с iOS, так как там меньше нюансов. 

iOS

Важный нюанс: пока в проекте присутствует Unity, сборка под iOS Simulator работать не будет. Только физическое устройство. Это означает, что вы теряете привычный цикл быстрой отладки на симуляторе. К этому стоит подготовиться заранее.

В первую очередь в unity-проект необходимо добавить код, чтобы ios могла общаться с unity. Нам потребуется два файла: NativeCallProxy.h и NativeCallProxy.mm. Им можно найти в репозитории самой библиотеки.

Вот что нужно сделать:

  1. В Unity откройте File → Build Settings и выберите платформу iOS. Нажмите Switch Platform.

  2. Затем откройте Player Settings. В разделе Other Settings укажите: Bundle Identifier, версию, IL2CPP как Scripting Backend

  3. Перейдите в раздел Identification и настройте Team ID и Provisioning Profile, если необходимо.

  4. В проекте добавьте файлы NativeCallProxy.h и NativeCallProxy.mm в Assets/Plugins/iOS.

  5. Вернитесь в Build Settings, нажмите Build, выберите папку (не внутрь RN-проекта!) — Unity сгенерирует Xcode-проект.

  6. Откройте его и выполните ручные настройки:

    • У Data и NativeCallProxy.h измените Target Membership на UnityFramework

    • Установите public для NativeCallProxy.h, чтобы он был доступен

  7. В Xcode выберите таргет UnityFramework, схему Any iOS Device (arm64) и соберите проект (Cmd + B).

  8. Найдите полученный UnityFramework.framework в Xcode → Products, щёлкните правой кнопкой, выберите Show in Finder, и скопируйте его в папку unity/builds/ios внутри вашего RN-проекта.

Android

Теперь нужно повторить то же упражнение, но уже для Android. В этот раз буду краток.

Сборка Android-проекта в Unity

  • В Unity выбираем Android как платформу.

  • По необходимости настраиваем Player Settings.
    Экспортируем исходники для Android (как делали для iOS).

Первая волна правок

Далее следуем инструкции из библиотеки:

  • В AndroidManifest.xml (ROOT_OF_YOUR_ANDROID_UNITY/unityLibrary/src/main/AndroidManifest.xml) находим и удаляем тег <intent-filter> вместе с его содержимым.

  • Вносим настройки в:

Вторая волна правок (эксклюзив!)

На этом библиотечная инструкция заканчивается, но я раскопал еще пару мест, которые нуждаются во внимании:

  • Глобальным поиском по проекту ищем mobilenotifications.androidlib и удаляем в конфигаруционных файлах все что с ним связано. 

  • После этого делаем Sync Gradle.

  • В структуре проекта появится одноимённый модуль — его можно удалить (необязательно, он и так не участвует в сборке, но зачем держать лишний мусор?).

Фокус с enableOnBackInvokedCallback

Возвращаемся к AndroidManifest.xml, где мы уже удаляли интенты.
Там есть тег <application> и в нём параметр: android:enableOnBackInvokedCallback="false"

Нужно убедиться, что это значение совпадает с тем, что указано в основном проекте. Эту настройку я не встречал ни в одном туториале или статье. Пришел к ней методом проб и ошибок. Без нее проект отказывался собираться. Дело в том, что она конфликтовала с нашим манифестом. Так что пришлось перевести значение в true.

Финал: копируем проект

После всех танцев с бубном — копируем весь игровой Android-проект целиком и вставляем его в: ROOT_YOUR_RN_PROJECT/unity/builds/android

Unity 6 не поддерживается (но мы хитрые)

Текущая версия библиотеки официально не поддерживает Unity 6, но мы можем это быстро починить. 

  1. Открываем файл: android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java

  2. Находим метод: public FrameLayout requestFrame()

  3. И правим catch-блок следующим образом:

diff --git a/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java b/android/src/main/java/com/azesmwayreactnativeunity/UPlayer.java

index b944779adbf2392ad8fed91bfd0f5a93c7fff28d..f9668b64827fe161717676bf1b562630e68b4d3b 100644

Вот тебе и «патч с любовью». Я оформил его с помощью yarn patch, если что.

Интеграция 

Честно скажу, я боялся, что это окажется самой сложной частью. Но к моему удивлению и радости, это оказалось максимально просто. Следуем инструкции из библиотеки и все будет хорошо. Можем подготовить методы для общения игры со стороны Unity. Я сделал простенький класс, который выглядел примерно так:

Этот код позволит нам отправлять ивенты в React Native.

Теперь нужно создать GameObject с этим компонентом в сцене и вызывать нужные методы в нужных местах. Примеры можно найти в документации библиотеки или в Unity-проекте.

Интеграция React Native → Unity

Теперь двигаемся в обратную сторону — React Native шлёт команды в Unity. Всё тоже довольно просто. В RN у нас будет доступен ref на unityView, у которого можно вызывать метод postMessage. Он принимает три строки:

  1. gameObject — важно! Это имя объекта, а не класса. Старайтесь, чтобы имя объекта совпадало с названием класса, обрабатывающего сообщение. Так будет меньше путаницы.

  2. methodName — метод, который вы хотите вызвать на стороне Unity.

  3. message — строка, которую передаём. Это может быть обычный текст, а может быть и JSON.stringify(object).

В моем коде это выглядело примерно так:

Теперь интеграция готова в обе стороны. Нам остается просто протестировать логику и проверить, что все работает хотя бы локально.

Пара советов

UI: Unity или React Native

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

Поэтому мой совет: делайте интерфейс в React Native и размещайте его поверх Unity-вьюхи с помощью абсолютного позиционирования. Так вы сохраните визуальную консистентность с остальным приложением, плюс получите бесплатную поддержку светлой/тёмной темы.

Разделяйте логику: геймплей vs бизнес

Старайтесь отделить игровую логику от бизнес-логики. Пусть Unity полностью отвечает за геймплей, физику, анимации и прочее веселье, а React Native — за всё, что связано с данными, аналитикой, навигацией и прочими взрослыми делами.

Идеально, если Unity просто отправляет события, а RN уже решает, что с ними делать.

Минимизируйте связь Unity ↔ RN

Каждое взаимодействие между Unity и RN — потенциальный источник багов, особенно если вы ещё не полностью уверены в стабильности геймплея. Постарайтесь сократить такие вызовы до минимума. Чем проще канал связи, тем спокойнее жизнь.

Тестируйте игру в изоляции

Перед тем как интегрировать игру в приложение, тщательно тестируйте её отдельно. Я, например, собирал отдельные билды под macOS, чтобы дать возможность всем заинтересованным пощупать игру без обёртки.

Это важно. Изменения в игровом процессе — штука неизбежная. Кто-то захочет изменить музыку, кто-то — сложность, кто-то — скорость появления врагов. А теперь вспомните, у нас нет hot reload'а. И будет совсем обидно, если вы закончите интеграцию, и тут вам скажут: «А давай сделаем другую музыку?». 

Тогда вам придется идти обратно в Unity Hub, вносить правки, тестировать, а потом… заново проходить через экспорт и подготовку исходников под платформы. То есть все, что я описывал в пунктах iOS и Android, придется делать заново. Это не страшно, но порядком утомительно.

Проблемы только начинаются

А сейчас я расскажу о реальных проблемах, с которыми мы столкнулись при развёртывании приложения. Я не нашел информации на эту тему. Ни в одной статье. Именно они и стали толчком к написанию этой статьи — хочется вас предупредить, а в идеале — вообще отговорить от использования Unity в вашем проекте. И начнём мы с самого очевидного.

iOS Simulator? Забудьте

Библиотека честно предупреждает: Unity не запускается на iOS Simulator. И это значит ровно то, что написано. Пока у вас в проекте есть Unity-игра — о симуляторе можно забыть. И это, мягко говоря, неудобно. Все React Native-разработчики, которых я знаю (и я в их числе), используют iOS-симулятор как основное средство разработки, прибегая к Android-эмулятору лишь для сверки. Так что подключая Unity, вы фактически лишаетесь привычного способа разработки.

Решения? Увы:

  • использовать реальное iOS-устройство;

  • или переключиться на Android-эмулятор.

Вес Unity-файлов

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

Я даже консультировался с Unity-разработчиками — вдруг я что-то делаю не так? Но серьёзных косяков никто не нашёл. Похоже, дело в версии Unity: чем новее, тем прожорливее.

Пример:

  • Flappy Bird под iOS — ~250 MB,

  • Flappy Bird под Android — ~800 MB (!). И это только исходники. Финальный билд, к счастью, выходит не таким огромным.

Но вся эта масса кода должна попасть в репозиторий. Git не обрадуется файлу на 250 MB. А вы не обрадуетесь, когда git push просто откажется это проглатывать.

Варианты решений:

  1. Удалять ненужные файлы вручную — звучит просто, но без опыта работы с Unity понять, что можно удалить, а что нет, практически невозможно.

  2. Оптимизировать экспорт в PlayerSettings: сжать текстуры, поиграться с Graphic API, Scripting Backend и т.д. Я пробовал — толку мало. Может, у вас получится лучше. Если да — расскажите в комментах, я серьёзно. В моем случае разница составила менее 1%.

  3. Git LFS — наше финальное решение. Устанавливаете, подключаете, трекаете большие файлы, пушите. Хорошее ли это решение? Трудно сказать, но на момент написания статьи другого я не нашел.

CI/CD: добро пожаловать в ад

Ошибки в CI/CD — это худшее, что может случиться. Почему? Потому что у вас всё работает, а на билд-машине — нет. Нужно каждый раз фиксить проблему наугад, заливать, ставить сборку, ждать 20-40 минут и надеяться, что проблема решена.

И именно тут у нас возник целый каскад трудностей.

Сначала у нас падали iOS-сборки, потому что на билд-машине не было unityFramework. Проблема оказалась в Git LFS — он не подтягивался по умолчанию на нашу билд-машину. Но это мы решили с третьей попытки.

Потом мы увидели проблему в Android. Она заключается в том, что по умолчанию Unity экспортирует проект с захардкоженным ndkPath. Выглядит он примерно так:

Как вы можете догадаться, на вашей билд-машине не будет никакой папки Applications, и уж тем более — Unity/Hub, откуда можно было бы подтянуть нужную версию NDK. Поэтому нам придётся ещё пошаманить и заменить путь к NDK на что-то вроде:

Мы будем прокидывать путь к нашему NDK из наших CI-скриптов. Для GitHub Actions есть специальный action, который это делает. Если у вас GitLab — можно настроить вручную. Тут в нагрузку добавляются проблемы с версионированием этих NDK, потому что где-то указана полная версия (как на скринах выше), а в репозитории NDK они указаны с буквенными обозначениями, так что придётся ещё поискать нужную. 🙂

И в этот момент мы начали ловить ошибки в CMAKE_C_COMPILER и CMAKE_CXX_COMPILER.

И… это стало концом. Я понял, что уже неделю пытаюсь наладить сборки, озадачил своей проблемой примерно половину техлидов компании и всех знакомых, кто мог хоть как-то помочь. И всё это не дало результатов. После ошибок CMAKE будет ещё сотня похожих. А релиз всё ближе.

Именно в этот момент я отказался от реализации Unity в нашем проекте и сделал быстрое решение на React Native Game Engine. О котором речь пойдёт дальше. Там будет сильно короче — обещаю.

Промежуточный итог

Вся секция про Unity может показаться бесполезной — ведь мы так и не довели игру до продакшена. Но я так не считаю.

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

Если кто-то из читателей всё-таки доведёт интеграцию Unity до production,  и поделитесь опытом в комментариях Тогда у нас — как у сообщества React Native-разработчиков — наконец появится готовая инструкция по решению не самой тривиальной задачи.

Пара слов о Unity в отрыве от интеграции в RN

Мне очень понравился dev experience при разработке игры на Unity: редактор интуитивно понятный, с кучей полезных функций, много готовых шаблонов, всё работает быстро, и туториалов — хоть залейся. А ещё — отличная производительность.

Я тестировал игру в нашем приложении на Samsung Galaxy A11, и всё работало как часы, без тормозов. Более того, я бы даже сказал, что игра в приложении работала лучше самого приложения.

React Native Game Engine

Тут все будет невероятно просто:

  1. Переходим на репозиторий библиотеки.

  2. Читаем документацию

  3. Находим туториал по созданию нужной вам игры

  4. Готово

Напомню, что мы здесь больше рассуждаем о разработке игр в составе RN-приложения, а не составляем пошаговое руководство по разработке конкретной игры. За такими подробностями можно обратиться к статьям из серии «Как сделать GAME NAME с помощью React Native Game Engine». Я же постараюсь разобрать плюсы и минусы этого подхода.

Начнем с плюсов:

  • Разработка на JS/TS, никаких новых ЯП.

  • Понятное API.

  • В отличии от Unity доступен Hot Reload.

Но на каждое преимущество технологии найдётся свой недостаток

  • "Game Engine" в названии — лукавство.
    Инструментария для отладки практически нет. Хотите проверить уничтожение или спавн объектов? Делайте это вручную. Изолированного тестирования игрового процесса — нет. Проверка коллизий? Только визуально, с помощью бордеров. Создание и удаление объектов — через console.log. Да, серьёзно.

  • Дополнительные зависимости. Придётся подключить:

    • matter.js — для физики,

    • ещё одну библиотеку — для воспроизведения музыки. 

Но, будем честны: для JS-разработчика пара новых зависимостей — не повод для паники.

Производительность страдает
Было сразу понятно, что RNGE не сравнится с Unity по производительности. Но реальность оказалась хуже, чем я ожидал. На флагманах (Realme 14 Pro, Samsung Galaxy S20FE, S22, iPhone 14 Pro) всё выглядело хорошо. А вот на бюджетных устройствах — производительность была на грани допустимого.

Возможные утечки памяти
В какой-то момент я заметил, что приложение с запущенной игрой начинает постепенно увеличивать потребление оперативной памяти. Я запустил игру с пустым полем — без объектов, без физики — но потребление всё равно росло. Более того, каждый новый запуск игры потреблял ещё больше памяти, несмотря на то, что я строго следовал инструкциям по корректной остановке движка.

Трудно сказать, была ли проблема в самом RNGE или в моём коде — без полноценного инструментария это проверить невозможно. Поэтому я не выношу этот пункт в отдельный "минус", но посчитал важным его упомянуть.

Компромисс, который заработал

Как и ожидалось, React Native Game Engine — крайне компромиссное решение: производительность и инструментарий явно оставляют желать лучшего.

Но! Все заработало. И, как вы могли заметить по супер-короткой инструкции, заработало буквально сразу — без миллиона подводных камней, которые утянули на дно нашу интеграцию с Unity.

В итоге главной проблемой RNGE стала производительность. Но поскольку релиз был буквально «вчера», мы признали это решение «good enough» и выпустили игру.

На этом история разработки и интеграции заканчивается. Я остался не до конца доволен результатом и хотел, уже без спешки, попробовать переехать на Godot после релиза. 

Если вы помните, мы изначально отказались от Godot потому, что библиотека для интеграции с React Native не поддерживала Android, а писать свою интеграцию тогда было некогда. Сейчас я как раз этим и занимаюсь. Godot, с Android-поддержкой, выглядит как идеальный вариант для внедрения игр в RN-приложения:

  • значительно проще Unity,

  • заметно легче — исходники занимают меньше места,

  • производительность на хорошем уровне.

Но процесс интеграции — не быстрый. Поэтому этот материал выходит без подробностей по Godot — возможно, это будет тема для следующего текста.

Вместо заключения

Внедрение игры в уже готовое, живое и не самое новое React Native-приложение — это действительно интересный и ценный опыт. И хотя идеального решения я пока не нашёл, кажется, что я на пути к нему.

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

Tags:
Hubs:
+5
Comments1

Articles