Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

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

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Итак, запасаемся кофе, открываем Unity и погнали!

Базовая настройка проекта. URP и все-все-все.

Начнем с того, что мы работаем с URP (Universal Render Pipeline). Почему так? Потому что он проще в настройке и обладает более гибким контролем, чем стандартный рендер. Ну и добиться хорошей производительности на тапках исходя из этого - намного проще.

Стоит указать, что ниже пойдет речь о 2D игре. Для 3D игр подходы будут несколько отличаться, как и настройки.

Мы реализовали два уровня графики. Low Level - для деревянных смартфонов и High Level - для флагманов. Уровни графики подключаются при помощи Project Settings.

В нашем проекте стоят следующие настройки (для Quality уровней):

Настройки графики для пресета Low в Project Settings
Настройки графики для пресета Low в Project Settings
Настройки пресета High
Настройки пресета High

На что здесь следует обратить внимание:

  • Texture Quality - качество текстур. Для High - мы берем полный размер текстур, для Low - Четверть. Можно еще внести Middle пресет с дополнительным уровнем.
  • Resolution Scaling везде стоит 1 - мы берем это значение из URP Asset.
  • VSync на Low уровне стоит отключить.
  • Все что связано с реалтаймом - отключаем.

Теперь перейдем к настройкам самих URP Asset. На что следует обратить внимание:

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры
Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Для разных уровней качества можно установить Render Scale - тем самым снижая разрешение для отрисовки. Также незабываем про Dynamic / Static батчинг.

Adaptive Performance

Отличная штука для автоматической подгонки производительности мобильных игр (в частности для Samsung-устройств):

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Другие полезные настройки:

  • Отключите 3D освещение, лайтмапы, тени и все что с этим связано.
  • Используйте для сборки IL2CPP - ускорьте работу вашего кода.
  • Используйте Color Space - Linear.
  • По-возможности подключите multithreaded rendering.

Игровой фреймворк

Едем дальше. URP и другие настройки проекта сделали. Теперь настало время поговорить о нашем ядре проекта. Что оно включает в себя?

Само ядро фреймворка включает в себя:

  • Игровые менеджеры для управления состояниями игры, аудио, переводов, работы с сетью, аналитикой, рекламными интеграциями и прочим.
  • Базовые классы для интерфейсов (компоненты, базовые классы View).
  • Классы для работы с контентом, сетью, шифрованием и др.
  • Базовые классы для работы с логикой игры.
  • Базовые классы для персонажей и пр.
  • Утилитарные классы (Coroutine Provider, Unix Timestamp, Timed Event и пр.)

Зачем нужны менеджеры?

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

Хотя мы и используем внедрение зависимостей, менеджеры состояний реализованы в качестве синглтонов и могут быть (и по их назначению должны быть) инициализированы единожды. А дальше мы просто можем использовать их:

AnalyticsManager.Instance().SendEvent("moreGamesRequested");

А уже сам менеджер распределяет, в какие системы аналитики, как и зачем мы отправляем эвент.

Базовые классы.

Здесь все просто. Они включают в себя базовую логику для наследования. К примеру, класс BaseView и его интерфейс:

namespace GameFramework.UI.Base { using System; public interface IBaseView { public void ShowView(ViewAnimationOptions animationOptions = null, Action onComplete = null); public void HideView(ViewAnimationOptions animationOptions = null, Action onComplete = null); public void UpdateView(); } }
namespace GameFramework.UI.Base { using System; using UnityEngine; using UnityEngine.Events; using DG.Tweening; internal class BaseView : MonoBehaviour, IBaseView { // Private Params [Header("View Container")] [SerializeField] private Canvas viewCanvas; [SerializeField] private CanvasGroup viewTransform; private void Awake() { // View Canvas Detecting if (viewCanvas == null) { viewCanvas = GetComponent<Canvas>(); if (viewCanvas == null) throw new Exception("Failed to initialize view. View canvas is not defined."); } // View Transform Detecting if (viewTransform == null) { viewTransform = GetComponent<CanvasGroup>(); if (viewTransform == null) throw new Exception("Failed to initialize view. View transform is not defined."); } // On Before Initialized OnViewInitialized(); } public virtual void OnViewInitialized() { } private void OnDestroy() { viewTransform?.DOKill(); OnViewDestroyed(); } public virtual void OnViewDestroyed() { } public virtual void UpdateView() { } public bool IsViewShown() { return viewCanvas.enabled; } public void ShowView(ViewAnimationOptions animationOptions = null, Action onComplete = null) { viewCanvas.enabled = true; if (animationOptions == null) animationOptions = new ViewAnimationOptions(); if (animationOptions.isAnimated) { viewTransform.DOFade(1f, animationOptions.animationDuration).From(0f) .SetDelay(animationOptions.animationDelay).OnComplete(() => { if (onComplete != null) onComplete(); OnViewShown(); }); } else { if (onComplete != null) onComplete(); OnViewShown(); } } public void HideView(ViewAnimationOptions animationOptions = null, Action onComplete = null) { if (animationOptions == null) animationOptions = new ViewAnimationOptions(); if (animationOptions.isAnimated) { viewTransform.DOFade(0f, animationOptions.animationDuration).From(1f) .SetDelay(animationOptions.animationDelay).OnComplete(() => { viewCanvas.enabled = false; if (onComplete != null) onComplete(); OnViewHidden(); }); } else { viewCanvas.enabled = false; if (onComplete != null) onComplete(); OnViewHidden(); } } public virtual void OnViewShown(){ } public virtual void OnViewHidden(){ } } }

А дальше мы можем использовать его, к примеру таким образом:

namespace Game.UI.InGame { using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; using GameFramework.UI.Base; using GameFramework.Components; using GameFramework.UI.Components; using GameFramework.Managers; using GameFramework.Models; using GameFramework.Models.Ads; using Game.Models; using GameFramework.Utils; internal class ToDoListView : BaseView { // View Context public struct Context { public Action onToDoListClosed; } private Context _ctx; // View Params [Header("View References")] [SerializeField] private Button closeButton; [SerializeField] private AudioClip clickButtonSFX; // Private Params private AudioSource _windowAudioSource; public ToDoListView SetContext(Context ctx) { _ctx = ctx; // Initialize Audio SOurce if (_windowAudioSource == null) { _windowAudioSource = transform.gameObject.AddComponent<AudioSource>(); transform.gameObject.AddComponent<AudioSettingsApplier>().currentAudioType = GameFramework.Models.AudioType.Sounds; } // Add Handlers closeButton.onClick.AddListener(() => { _ctx.onToDoListClosed.Invoke(); PlayClickSoundSFX(); }); return this; } public override void OnViewDestroyed() { closeButton.onClick.RemoveAllListeners(); } public override void UpdateView() { } private void PlayClickSoundSFX() { if (_windowAudioSource != null && clickButtonSFX!=null) { _windowAudioSource.playOnAwake = false; _windowAudioSource.clip = clickButtonSFX; _windowAudioSource.loop = false; _windowAudioSource.Play(); } } } }

Классы для работы с контентом, сетью, шифрованием

Ну здесь все просто и очевидно. Вообще, у нас реализовано несколько классов:

1) Классы шифрования (Base64, MD5, AES и пр.)

2) FileReader - считывающий, записывающий файл, с учетом кодировки, шифрования и других параметров. Также он умеет сразу сериализовать / десериализовать объект в нужном формате и с нужным шифрованием.

3) Network-классы, которые позволяют удобно работать с HTTP-запросами, работать с бандлами / адрессаблс и др.

Утилитарные классы

Здесь у нас хранятся полезные штуки, вроде Unix Time конвертера, а также костыли (вроде Coroutine Provider-а).

Unix Time Converter:

namespace GameFramework.Utils { using UnityEngine; using System.Collections; using System; public static class UnixTime { public static int Current() { DateTime epochStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); int currentEpochTime = (int)(DateTime.UtcNow - epochStart).TotalSeconds; return currentEpochTime; } public static int SecondsElapsed(int t1) { int difference = Current() - t1; return Mathf.Abs(difference); } public static int SecondsElapsed(int t1, int t2) { int difference = t1 - t2; return Mathf.Abs(difference); } } }

Костыль Coroutine-Provider:

namespace GameFramework.Utils { using System.Collections; using System.Collections.Generic; using UnityEngine; public class CoroutineProvider : MonoBehaviour { static CoroutineProvider _singleton; static Dictionary<string,IEnumerator> _routines = new Dictionary<string,IEnumerator>(100); [RuntimeInitializeOnLoadMethod( RuntimeInitializeLoadType.BeforeSceneLoad )] static void InitializeType () { _singleton = new GameObject($"#{nameof(CoroutineProvider)}").AddComponent<CoroutineProvider>(); DontDestroyOnLoad( _singleton ); } public static Coroutine Start ( IEnumerator routine ) => _singleton.StartCoroutine( routine ); public static Coroutine Start ( IEnumerator routine , string id ) { var coroutine = _singleton.StartCoroutine( routine ); if( !_routines.ContainsKey(id) ) _routines.Add( id , routine ); else { _singleton.StopCoroutine( _routines[id] ); _routines[id] = routine; } return coroutine; } public static void Stop ( IEnumerator routine ) => _singleton.StopCoroutine( routine ); public static void Stop ( string id ) { if( _routines.TryGetValue(id,out var routine) ) { _singleton.StopCoroutine( routine ); _routines.Remove( id ); } else Debug.LogWarning($"coroutine '{id}' not found"); } public static void StopAll () => _singleton.StopAllCoroutines(); } }

Логика сцен

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

Каждая сцена - содержит в себе основной Installer, который помимо различных View, подключает логические блоки - своеобразные куски механик:

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Эти куски механик последовательно выполняются, отдавая события OnInitialize, OnProgress, OnComplete. Когда последний блок сыграет свой OnComplete - он завершит работу сцены (закончит уровень).

Зачем это сделано?

  • Мы можем собирать каждую сцену из отдельных механик, как конструктор. Это может быть диалог -> поиск предметов -> катсцена -> поиск предметов -> диалог, или любой другой порядок.
  • Мы можем сохранять прогресс внутри сцены, привязываясь к определенному блоку.
  • Блоки механик удобнее изменять, нежели огромный инсталлер с кучей разных контроллеров.

Работа с контентом

При работе с контентом, мы стараемся делать упор на оптимизацию. В игре содержится много UI, скелетные 2D анимации, липсинк и прочее. Вообще, контента достаточно много, не смотря на простоту игры.

Анимации в игре

Самый удобный для нас вариант - оказался из коробки. Мы используем систему для работы с костной анимацией от самой Unity:

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Да, можно взять Spine, но у нас нет настолько большого количества анимаций, поэтому вариант от Unity - весьма оптимален.

Упаковка и сжатие

Все, что можно и нужно запихнуть в атласы - мы запихиваем в атласы и сжимаем. Это могут быть элементы UI, иконки и многое другое. Для упаковки атласов - используем стандартный Unity пакер из Package Manager (V1):

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Локализация

Вся локализация базируется на JSON. Мы планируем отказаться от этого в ближайшее время, но пока что на время Soft-Launch этого хватает:

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Работа с UI

При работе с UI мы разбиваем каждый View под отдельный Canvas. 99% всех анимаций работает на проверенном временем DOTween и отлично оптимизирован.

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

View инициализируются и обновляются по запросу через эвенты, которые внедряются в Level Installer, либо в отдельных блоках логики.

Разработка мобильных игр на Unity в современных реалиях. URP, 2D Animation и все-все все на примере игры

Что мы используем еще?

  • Salsa - для липсинка;
  • 2D Lighting - для освещения. В большинстве сцен используется освещение по маске спрайта;
  • DOTween - для анимаций;

Итого

Работа с механиками получается достаточно гибкой за счет блоков логики, внедрение зависимостей - позволяет контролировать оптимизацию кода. У нас собственная реализация внедрения зависимостей, как и системы реактивных событий, дабы не громоздить Zenject + UniRX. Да, мы сделали проще, но нам и не нужно всех возможностей этих огромных библиотек.

Полезные ссылки:

Надеюсь, вам было полезно.

Готов ответить на все вопросы. :)

104104
26 комментариев

Комментарий недоступен

18
Ответить

Ну кода тут кот наплакал. Скорее просто как пример

1
Ответить

Нихера не понятно , но очень интересно.

4
Ответить

DOTween - хорошая штука, но его "документация" - это пример того, как писать хэлпы нельзя. Там просто описание синтаксиса, без внятных ответов на вопросы "хочу X, когда Y" или "зачем для Z три разных механизма реализации".

Например, о том, что добавленный через визуальный редактор путь нельзя корректировать из скрипта в рантайме, мне удалось узнать только методом разбора исходников. Такие вещи в хороших домах объясняют...

1
Ответить

Документация это тема для отдельного разбора. Хороший разработчик не всегда может хорошо написать доку 🤣

1
Ответить

Отличная статья все по делу и ничего лишнего.

1
Ответить

Спасибо за статью!

У вас перепутаны скриншоты с настройками Project Settings для Low и Quality.

1
Ответить