Как разрабатывать игры на Unity: шаблоны проектирования и хорошие практики

Всем привет! Я Виктор Антоненко, Lead Unity-разработчик в компании OBRIO. Мы ― часть экосистемы бизнесов Genesis и занимаемся разработкой мобильных приложений и игр. Кто со мной еще не знаком, может узнать подробнее в серии статей о том, как сделать и запустить свой первый игровой проект.

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

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

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

Шаблоны проектирования (design patterns)

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

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

Рассмотрим, какие бывают шаблоны проектирования.

Observer — поведенческий шаблон проектирования, решает проблему множественного оповещения об изменении состояния одного объекта.

Например, у игрока есть множество противников, которые его преследуют. Он в этой ситуации является субъектом и будет содержать список подписчиков-преследователей, которые выступают наблюдателями. И будет оповещать их о смене своей позиции на локации. Когда преследователи получат сигнал об изменении позиции цели — игрока, они будут реагировать, бросая или начиная преследование в зависимости от дистанции до цели.

using System.Collections.Generic;
using UnityEngine;

public interface IObserver
{
    void OnPositionChanged(Vector3 newPosition);
}


public interface ISubject 
{
    void Subscribe(IObserver observer);
    void Unsubscribe(IObserver observer);
    void NotifyObservers(Vector3 newPosition);
}


public class PlayerTarget : MonoBehaviour, ISubject
{
    private List<IObserver> observers = new List<IObserver>();

    private void OnPositionChanged(Vector3 newPosition)
    {
        //Move player
        NotifyObservers(newPosition);
    }

    public void Subscribe(IObserver observer)
    {
        observers.Add(observer);
    }

    public void Unsubscribe(IObserver observer)
    {
        observers.Remove(observer);
    }

    public void NotifyObservers(Vector3 newPosition)
    {
        foreach(IObserver enemy in observers)
        {
            enemy.OnPositionChanged(newPosition);
        }
    }
}


public class EnemyObserver : MonoBehaviour, IObserver
{
    [SerializeField]
    private float pursueDistance;

    private ISubject player;
    public void Init(ISubject player)
    {
        this.player = player;
        player.Subscribe(this);
    }

    private void OnDestroy()
    {
        player.Unsubscribe(this);
    }

    public void OnPositionChanged(Vector3 newPosition)
    {
        if(Vector3.Distance( transform.position, newPosition) <= pursueDistance)
        {
            //pursue player
        }
        else
        {
            //idle
        }
    }
}

Минусы:

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

Стратегия (Strategy) — поведенческий шаблон проектирования, который решает проблему замены поведения класса в зависимости от входящих данных или типа клиента.

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

public abstract class BaseStrategy
{
    public event Action<int> MinigameFinishedEvent = (score) => { };
    public abstract void StartMiniGame();
    public virtual void FinishMiniGame(int score)
    {
        MinigameFinishedEvent?.Invoke( score);
    }

    protected virtual int Scenario1()
    {
        return UnityEngine.Random.Range(0, 100);
    }

    protected virtual int Scenario2()
    {
        int score = UnityEngine.Random.Range(0, 200) - UnityEngine.Random.Range(0, 200);
        return Mathf.Clamp(score, 0, 200);
    }

    protected virtual int Scenario3()
    {
        return UnityEngine.Random.Range(0, 1) * 1000;
    }
}




public class MiniGame1 : BaseStrategy
{
    public override void StartMiniGame()
    {
        int score = 0;
        score += Scenario1();
        score += Scenario2();
        score += Scenario3();
        FinishMiniGame(score);
    }
}


public class MiniGame2 : BaseStrategy
{
    public override void StartMiniGame()
    {
        int score = 0;
        score += Scenario2();
        score +=  Scenario2();
        score +=  Scenario3();
        score +=  Scenario3();
        FinishMiniGame(score);
    }
}


public class MainGame : MonoBehaviour
{
    private BaseStrategy currentMinigame;
    private void StartMinigame(int type)
    {
        switch (type)
        {
            case 0:
                currentMinigame = new MiniGame1();
                break;
            case 1:
                currentMinigame = new MiniGame2();
                break;
            default:
                Debug.LogError($"Minigame of type{type} not implemented yet!");
                break;
        }

        if(currentMinigame != null)
        {
            currentMinigame.MinigameFinishedEvent += MinigameFinished;
            currentMinigame.StartMiniGame();
        }
    }

    private void MinigameFinished(int score)
    {
        currentMinigame.MinigameFinishedEvent -= MinigameFinished;
        currentMinigame = null;
    }
}

Минусы:

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

Пул объектов (Object pool) — порождающий шаблон проектирования, позволяющий переиспользовать объекты вместо того, чтобы их постоянно создавать и удалять. Имеет два запроса — взятие из пула и возврат в пул.

Если при запросе взятия из пула нет объектов нужного типа, они создаются.

Операция возврата в пул используется вместо удаления объектов. Объекты сбрасывают свое состояние, выключаются и возвращаются в пул.

Object pool широко применяют в играх, написанных на Unity, из-за следующих причин:

  • Каждая операция создания с помощью Instantiate потребляет много ресурсов;
  • Сборщик мусора в Unity имеет только одно поколение, и каждая операция сборки мусора приводит к проседанию производительности.

Как пример приведу рабочую реализацию объектного пула. Класс, отвечающий за пул объектов, проводит генерацию и выдачу объекта из пула, не имея представления о том, какие именно объекты производит. Единственное требование — создаваемый инстанс префаба обязательно должен содержать скрипт-наследник интерфейса IObjectPoolItem.

Для хранения пулов разных объектов используются словари с очередями. Как ключ задается тип объекта PoolObjectType. При взятии объекта из пула передается его тип и префаб (если нужно произвести новый объект этого типа), родительский объект для положения в иерархии и позиция, куда нужно поместить объект.

Пример кода:

public interface IObjectPoolItem
    {
        string PoolObjectType { get; }
        void GetFromPool();
        void ReturnToPool();
    }

public class ObjectPoolModule : MonoBehaviour
{
    Dictionary<string, Queue<IObjectPoolItem>> appObjectPool;

    private void OnEnable()
    {
        appObjectPool = new Dictionary<string, Queue<IObjectPoolItem>>();
    }

    public IObjectPoolItem GetObjectFromPool(string objectType, GameObject objectToInstantiate, Vector3 pos, Transform parent = null)
    {
        IObjectPoolItem result = null;
        if (appObjectPool.ContainsKey(objectType))
        {
            if (appObjectPool[objectType].Count > 0)
            {
                bool endQueue = false;
                bool found = false;
                while (endQueue == false && found == false)
                {
                    if (appObjectPool[objectType].Count > 0)
                    {
                        IObjectPoolItem item = appObjectPool[objectType].Peek();
                        MonoBehaviour script = item as MonoBehaviour;
                        if ((item != null && script != null && script.gameObject != null) == false)
                        {
                            item = appObjectPool[objectType].Dequeue();
                        }
                        else
                        {
                            found = true;
                        }
                    }
                    else
                    {
                        endQueue = true;
                    }
                }
                if (endQueue || found == false)
                {
                    result = CreateObject(objectToInstantiate, pos, parent);
                }
                else
                {
                    result = appObjectPool[objectType].Dequeue();
                }
            }
            else
            {
                result = CreateObject(objectToInstantiate, pos, parent);
            }
        }
        else
        {
            Queue<IObjectPoolItem> newObjectsQueue = new Queue<IObjectPoolItem>();
            appObjectPool.Add(objectType, newObjectsQueue);
            result = CreateObject(objectToInstantiate, pos, parent);
        }
        result.GetFromPool();
        return result;
    }

    private IObjectPoolItem CreateObject(GameObject objectToInstantiate, Vector3 pos, Transform parent = null)
    {
        GameObject createdObject;
        if (parent == null)
        {
            createdObject = Instantiate(objectToInstantiate, pos, Quaternion.identity);
        }
        else
        {
            createdObject = Instantiate(objectToInstantiate, parent, false);
        }

        IObjectPoolItem poolableData = createdObject.GetComponent<IObjectPoolItem>();
        if (poolableData == null)
        {
            Debug.LogError("Invalid cast to interface IObjectPoolItem");
        }

        return poolableData;
    }

    public void ReturnToPool(IObjectPoolItem objectReturnedToPool)
    {
        if (appObjectPool != null)
        {
            if (appObjectPool.ContainsKey(objectReturnedToPool.PoolObjectType))
            {
                appObjectPool[objectReturnedToPool.PoolObjectType].Enqueue(objectReturnedToPool);
            }
            else
            {
                Queue<IObjectPoolItem> newObjectsQueue = new Queue<IObjectPoolItem>();
                newObjectsQueue.Enqueue(objectReturnedToPool);
                appObjectPool.Add(objectReturnedToPool.PoolObjectType, newObjectsQueue);
            }
        }
        objectReturnedToPool.ReturnToPool();
    }
}

Минусы:

  • Без ручной очистки объекты будут и дальше храниться в выключенном состоянии, даже если уже не будут нужны.
  • Необходимо сбрасывать все данные о предыдущем состоянии объекта при возврате в пул, чтобы сделать его пригодным для переиспользования.
  • Если объект содержит секретную информацию, то при возврате в пул необходимо ее очистить, иначе это создаст риск утечки данных.

ECS — это архитектурный шаблон, который в основном используется в разработке игр. Он определяет четкие уровни абстракции и порядок допустимых связей между сущностями программы.

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

System — основной класс, содержащий список связанных с ним Entity, управляющий ими и выполняющий глобальную логику. В нашей реализации использует шаблон Observer для всех своих Entity.

Entity — структурная единица каждой отдельной системы, зачастую несет уникальный ID, сохраняет данные о состоянии конкретной Entity и управляет вызовами для прикрепленных Component’ов.

Component — скрипт низкого уровня, выполняющий конкретные действия, связанные только с определенным Entity.

Для примера возьмем игру в жанре стратегия. Разберем две системы: первая отвечает за пользовательский ввод, вторая — за управление юнитами. Система ввода отправляет сигналы о действиях пользователя. Система управления юнитами получает данные и интерпретирует для выдачи приказов юнитам.

Например, задаем точку передвижения юниту. После этого система управления юнитами отправляет приказ начать передвижение на Entity, который записывает этот приказ в очередь и передает сигнал на свои компоненты: в скрипт передвижения передает точку назначения и начинает двигать объект; в скрипт-обертке аниматора меняет состояние анимации с Idle на Move и запускает соответствующий анимационный ролик.

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

В этой реализации для связывания систем между собой используется сущность «экран» (Screen), которая объединяет в себе состояние и управление пользовательским интерфейсом. Когда пользователь попадает на экран и уходит с него, системы подписываются и отписываются для того, чтобы осуществить ожидаемое поведение для текущего состояния.

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

Также существует Unity ECS. Подробнее об этом решении можно почитать на официальном обучающем ресурсе.

Преимущества ECS:

  • Упорядоченность уровней иерархии и связей.
  • Низкая связность кода и модульность — можно извлечь целую систему из одной игры и перенести в другую.
  • Entity хранятся в памяти рядом, позволяя экономить место при распределении памяти.
  • Простота создания загрузки/сохранения. Чтобы сохранить игру, необходимо сохранить и воспроизвести состояния всех Entity всех систем.

Недостатки ECS:

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

Кроме перечисленных выше шаблонов, я бы советовал ознакомиться со состоянием (State) и абстрактной фабрикой (Abstract Factory).

Хорошие практики и советы

1. Создать точки входа приложения, отказаться от инициализации в Awake/Start. Без единой точки входа невозможно управлять инициализацией, и вы столкнетесь с проблемой того, что класс, зависящий от данных другого класса, будет вызывать свой Awake/Start раньше, из-за чего возникнут ошибки.

Подход к созданию точки входа может быть разный: класс управления инициализацией, который вызывает инициализацию объектов в определенном порядке, или создание всей структуры сцены с помощью фабрик — выбор за вами.

2. Использовать концепцию единой точки выхода из метода. Простой подход, позволяющий создавать понятный код. Если у метода будет единственный return, то не нужно будет отслеживать все возможные варианты выхода.

3. Отказаться от Update в каждом скрипте. Этот подход повысит производительность игры — использование шаблона «наблюдатель» для всех объектов, требующих вызовов Update, вместо самостоятельного вызова обновлений. Также порядок вызова Update станет управляемым.

4. Не использовать конкатенацию при работе со строками. Операция конкатенации (string + string) приводит к утечкам памяти, что может ощущаться, например, при обновлении значений таймеров в методе Update с выводом в текст на GUI. Использование StringBuilder не приводит к утечкам памяти.

5. Использовать атрибут [SerializeField] вместо спецификатора доступа public. Таким образом можно выводить поля для заполнения в инспекторе, не делая их открытыми.

Как не стоит делать

Одиночка (Singleton) — порождающий шаблон проектирования, очень простой для понимания и использования, предполагает, что в программе будет только один экземпляр класса и к нему будет создаваться упрощенный доступ. Рассмотрим пример кода Singleton, наследующего MonoBehaviour:
public class SingletonExample : MonoBehaviour
{
    public static SingletonExample Instance { get; private set; }

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Destroy(gameObject);
        }
    }

    public void DoSomething()
    {
        // actions
    }

}

Использование конструктора для класса-наследника MonoBehaviour нежелательно, для инициализации лучшим выбором будет метод Awake. После этого можно обратиться к методу экземпляра одиночки с помощью подобного вызова:

SingletonExample.Instance.DoSomething()

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

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

Пример плохой архитектуры — неправильное применение DI на примере IoC контейнера Zenject

Дисклеймер: это не критика Zenject и тех, кто его использует, скорее демонстрация того, куда может завести бездумное внедрение технологий.

Начну с примера из моей практики. Как-то я устроился на работу, где первым проектом была финализация игры. Там я впервые столкнулся с применением Zenject для создания связей. Мне казалось, что программист, внедривший его, делал это потому, что слышал, что все так делают и это круто.

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

Это приводило к тому, что при любом действии параметры запаковывались в массив типа object и рассылались броадкастом на всех подписчиков, которыми являлись все скрипты игры. А каждый скрипт-подписчик должен был понять, связано ли действие с ним и необходимо ли его обрабатывать. Более того, этот метод связи запускался, даже если нужно было совершить синхронный вызов внутри одного класса. На «Хабре» можно рассмотреть подробный пример неправильного использования Zenject.

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

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

Решение подобной проблемы было настоящим распутыванием спагетти, поскольку, убирая отправку одного сообщения, необходимо было искать все классы, подписанные на его прием, и постепенно переписывать код на адекватные вызовы и создание понятных связей.

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

Выводы

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

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

Стоит уделить должное внимание проектированию архитектуры на ранних этапах разработки и помнить, что использование антипаттернов может привести к серьезным проблемам в будущем.

Если вам было интересно и хочется узнать больше о разнообразных шаблонах, советую прочесть книгу Game Programming Patterns Роберта Нистрема.

Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному9
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хочется похоливарить про «едину точка выхода». А как же Guard Clause? Писать лесенку ифов с временными переменными? Фаулер например напрямую рекомендует рефакторить такой код. В чем плюс одного ретурна и откуда вообще взялась идея того что нужно стараться иметь один ретурн? Я все пытаюсь найти первоисточник и все никак не могу

По моєму досвіду функції з однією точкою вихода більш читабельні і їх простіше підтримувати, бо ти точно знаєш де вона починається і де закінчуюється і не потрібно будет шукати яка саме точка виходу спрацювала, достатньо перевірити вихідне значення.
Ця позиція базується на моєму досвіді і стандартах розробки впродовж останніх 2-3х років.

Дійсно, Zenject часто використовують, щоб додавати глобальні об‘єкти в поля кожного MonoBehaviour, і таким чином вбивають всю архітектуру.

Головне — не копіювати сліпо код прикладів паттернів зі статтей в мережі, а мати свою голову на плечах і оцінити результат з точки зору базових принципів, таких як SOLID.

Спасибо за полезную статью! :)

Підписатись на коментарі