Всем привет! Это снова Илья и сегодня мы поговорим о технической реализации мобильной игры в современных реалиях. Статья не претендует на уникальность, однако в ней вы можете найти для себя что-то полезное. А чтобы рассмотреть разработку на реальном проекте - мы возьмем реализацию нашей игры, которая на днях выходит в 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, но решили отказаться от нагромождения большой системы. Да, мы сделали проще, но нам и не нужно всех возможностей этих огромных библиотек.

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

http://dotween.demigiant.com/

https://assetstore.unity.com/packages/tools/animation/salsa-lipsync-suite-148442

https://docs.unity3d.com/Packages/com.unity.render-pipelines.universal@7.1/manual/Lights-2D-intro.html

https://docs.unity3d.com/Packages/com.unity.2d.animation@1.0/manual/index.html