Разработка мобильных игр на Unity. URP, 2D Animation и другие новомодные вещи на примере игры
Всем привет! Это снова Илья и сегодня мы поговорим о технической реализации мобильной игры в современных реалиях. Статья не претендует на уникальность, однако в ней вы можете найти для себя что-то полезное. А чтобы рассмотреть разработку на реальном проекте - мы возьмем реализацию нашей игры, которая на днях выходит в Soft-Launch.
Дисклеймер! Код в этой статье не проходил рефакторинг и носит лишь ознакомительный характер, чтобы поделиться идеями. И вообще, в целом, это smellscode.
Итак, запасаемся кофе, открываем Unity и погнали!
Базовая настройка проекта. URP и все-все-все.
Начнем с того, что мы работаем с URP (Universal Render Pipeline). Почему так? Потому что он проще в настройке и обладает более гибким контролем, чем стандартный рендер. Ну и добиться хорошей производительности на тапках исходя из этого - намного проще.
Стоит указать, что ниже пойдет речь о 2D игре. Для 3D игр подходы будут несколько отличаться, как и настройки.
Мы реализовали два уровня графики. Low Level - для деревянных смартфонов и High Level - для флагманов. Уровни графики подключаются при помощи Project Settings.
В нашем проекте стоят следующие настройки (для Quality уровней):
Настройки графики для пресета Low в Project Settings:
На что здесь следует обратить внимание:
Texture Quality - качество текстур. Для High - мы берем полный размер текстур, для Low - Четверть. Можно еще внести Middle пресет с дополнительным уровнем.
Resolution Scaling везде стоит 1 - мы берем это значение из URP Asset.
Все что связано с реалтаймом - отключаем.
Теперь перейдем к настройкам самих URP Asset. На что следует обратить внимание:
Для разных уровней качества можно установить Render Scale - тем самым снижая разрешение для отрисовки. Также незабываем про Dynamic / Static батчинг.
Adaptive Performance
Отличная штука для автоматической подгонки производительности мобильных игр (в частности для Samsung-устройств):
Другие полезные настройки:
Отключите 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, подключает логические блоки - своеобразные куски механик:
Эти куски механик последовательно выполняются, отдавая события OnInitialize, OnProgress, OnComplete. Когда последний блок сыграет свой OnComplete - он завершит работу сцены (закончит уровень).
Зачем это сделано?
Мы можем собирать каждую сцену из отдельных механик, как конструктор. Это может быть диалог -> поиск предметов -> катсцена -> поиск предметов -> диалог, или любой другой порядок.
Мы можем сохранять прогресс внутри сцены, привязываясь к определенному блоку.
Блоки механик удобнее изменять, нежели огромный инсталлер с кучей разных контроллеров.
Работа с контентом
При работе с контентом, мы стараемся делать упор на оптимизацию. В игре содержится много UI, скелетные 2D анимации, липсинк и прочее. Вообще, контента достаточно много, не смотря на простоту игры.
Анимации в игре
Самый удобный для нас вариант - оказался из коробки. Мы используем систему для работы с костной анимацией от самой Unity:
Да, можно взять Spine, но у нас нет настолько большого количества анимаций, поэтому вариант от Unity - весьма оптимален.
Упаковка и сжатие
Все, что можно и нужно запихнуть в атласы - мы запихиваем в атласы и сжимаем. Это могут быть элементы UI, иконки и многое другое. Для упаковки атласов - используем стандартный Unity пакер из Package Manager (V1):
Локализация
Вся локализация базируется на JSON. Мы планируем отказаться от этого в ближайшее время, но пока что на время Soft-Launch этого хватает:
Работа с UI
При работе с UI мы разбиваем каждый View под отдельный Canvas. 99% всех анимаций работает на проверенном временем DOTween и отлично оптимизирован.
View инициализируются и обновляются по запросу через эвенты, которые внедряются в Level Installer, либо в отдельных блоках логики.
Что мы используем еще?
Salsa - для липсинка;
2D Lighting - для освещения. В большинстве сцен используется освещение по маске спрайта;
DOTween - для анимаций;
Итого
Работа с механиками получается достаточно гибкой за счет блоков логики. Мы изначально думали взять связку Zenject + UniRX, но решили отказаться от нагромождения большой системы. Да, мы сделали проще, но нам и не нужно всех возможностей этих огромных библиотек.
Полезные ссылки:
https://assetstore.unity.com/packages/tools/animation/salsa-lipsync-suite-148442
https://docs.unity3d.com/Packages/com.unity.2d.animation@1.0/manual/index.html