Логотип проекта Unity

Паттерны. "Singleton", "Decorator". Unity

Паттерны. "Singleton", "Decorator". Unity

Здравствуйте уважаемые читатели данной статьи. Сегодня я решил поговорить о ОО-проектировании в сфере геймдева, используя движок Unity. Сразу скажу, что данная статься является объективным видением использования паттернов и в особенности, их реализация. Кто-то может говорить о том, что в моих далее приведенных примерах лучше использовать тот или иной паттерн и возможно вы будете правы, но моя задача как минимум поверхностно пройтись по этой достаточно сложной теме. Я рассчитываю сделать небольшой цикл статей про разные паттерны и их примерное использование, чтобы архитектура вашего проекта стала гибкой и расширяемой.
Также хочу отметить, что данный цикл будет требовать определенных знаний в области ОО-программирования и базового ознакомления с понятием "паттерн".
Вроде всё оговорил, можем начинать!

Паттерн "Одиночка"

Данный паттерн предназначен для реализации единственного, уникального объекта во всей программе. При разработке игры вы можете столкнутся даже не один раз, когда вам нужно будет реализовать какой-либо модуль для игры (например систему сохранения игры или систему достижений). За продолжительное время в геймдеве я столкнулся с несколькими подходами к реализации уникальных объектов, но сегодня я хочу рассказать о том, на котором остановился на данный момент.
Былое: Ранее, я думал, что все подобные модули, которые я приводил в пример, должны быть уникальными объектами и делал каждый такой модуль на базе паттерна "Одиночка", но вскоре понял, что есть и более элегантный способ.
Собственно, я пришёл к выводу, что лучше всего сделать один единственный класс-одиночку, а внутри него хранить экзмепляры классов, которые могут быть задействованы из любой точки программы. Иначе говоря, у меня есть один класс на базе "Одиночка", который даёт доступ только для чтения всех ранее проинициализированных экземпляров классов.
Вот собственно его реализация:

using UnityEngine;

namespace Game.Main
{
    public class GameStorage : MonoBehaviour
    {
        public static GameStorage Instance { get; private set; }
        public FileManager FileManager { get; private set; }
        public AchievementsManager AchievementsManager { get; private set; }

        private void Awake()
        {
            CheckInstance();
            InitManagers();      
        }

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

        private void InitManagers()
        {
            FileManager = gameObject?.GetComponent<FileManager>();
            AchievementsManager = gameObject?.GetComponent<AchivementsManager>();
        }
    }
}

Как вы можете видеть, класс-одиночка не сильно привязан к тем экземплярам, которые инициализируются за счёт оператора '??', а также инициализирует он их на том же игровом объекте, на котором находится сам. Это значит, что если вам не обязательно добавлять все модули сразу. С другой стороны, если вы забудете добавить нужный модуль (модуль в моей интерпретации это управляющий класс), то движок вам напомнить не сможет.

Примечание:
Помните, что это пример на Unity и лишь одна из интерпретаций паттерна. Дело в том, что данный вариант не будет работать корректно с несколькими потоками. Возможным решением может стать ключевое слово для статического экзмепляра одиночки "volatile". С учётом новой Job System в Unity, для меня неизвестно, как именно будет работать одиночка, стоит проверить.

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

Более подробнее ознакомится с паттерном можно будет здесь

Паттерн "Декоратор"

Лично я вижу сложность в том, чтобы придумать серьезную практическую задачу для данного паттерна. Его суть в том, что он создаёт некоторые дополнения для одного объекта. Это если говорить двумя словами, но на деле всё может показаться чуть сложнее.
Предположим, что у нас есть класс "Weapon". Мы хотим, чтобы наше оружие могло иметь разные модификации (глушитель, прицел, например), это и будут называться "дополнения" для одного объекта "Weapon". Сам процесс добавления таких дополнений реализован посредством "оборачивания" объекта "Weapon".
В нашем случае, рассмотрим простой пример с пистолетом и глушителем и выведем конечное описание оружия.
Создадим для начала абстрактный класс Weapon:

using UnityEngine;

namespace Patterns.Decorator
{

    public abstract class Weapon : MonoBehaviour
    {
        [SerializeField]
        private string nameWeapon;       

        public abstract string GetDescription();
        public string GetWeaponName() => nameWeapon;
    }
}

Далее, чтобы добавлять разные модные штучки на нашу мощную пушку, сделаем класс-обертку, которая будет являться классом-наследником для класса "Weapon" и назовём этот класс "WeaponWrapper". Вот как он выглядит:

using UnityEngine;

namespace Patterns.Decorator
{

    public abstract class WeaponWrapper : Weapon
    {
        protected Weapon weapon;

        public void SetWeapon(Weapon weapon) => this.weapon = weapon;

        public override string GetDescription() => weapon.GetDescription();

    }
}

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

using UnityEngine;

namespace Patterns.Decorator
{

    public class WeaponPistol : Weapon
    {
        private WeaponWrapper muffler = new Muffler();

        public override string GetDescription()
        {
            return GetWeaponName();
        }

        private void Start()
        {
            Debug.Log(GetDescription());
            muffler.SetWeapon(this);
            Debug.Log(muffler.GetDescription());
        }
    }

}

Что такое "Muffler"? Сейчас покажу, но скажу пока кое-что на счёт данного класса. В данном примере я хочу выводить описание нашего оружия. Как видите, здесь я вывожу описание сначала пистолета (просто название оружия), а потом описание объекта "Muffler". Перейдём теперь к классу "Muffler".

Сделаем класс, который будет одним из модификаций для нашей пушки, пусть это будет глушитель. Класс будет называться "Muffler" и выглядеть так:

using UnityEngine;

namespace Patterns.Decorator
{

    public class Muffler : WeaponWrapper
    {

        public override string GetDescription()
        {
            return weapon.GetWeaponName() + " с глушителем";
        }
    }
}

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

Паттерны. "Singleton", "Decorator". Unity — Unity — DevTribe: Разработка игр (GameDev, patterns, Unity)

Как видите, в данном случае, не оружие хранит модификации, а модификации хранят оружие. В этом специфика данного паттерна. Возможно, с точки зрения логики, было бы правильнее хранить модификации оружия внутри класса оружия, однако, как я говорил ранее, придумать пример на базе такого паттерна в сфере разработки игр для меня оказалось достаточно интересной задачей. Может быть ваш пример будет гораздо лучше? Если так, то предлагайте, будет интересно посмотреть!
В конце хочется отметить, что данный паттерн хорошо используете в комбинации с паттерном "абстрактная фабрика", но сегодня мы рассмотрели чистый паттерн "декоратор" суть которого дополнять объект новыми свойствами без изменения классов самих оружий.

Более подробно ознакомится с паттерном можно здесь

Спасибо за внимание!



Весьма интересный подход к реализации синглтона в Unity. Надо взять на заметку, а то я сейчас просто через FindObject пытаюсь найти инстанс к таким важным вещам. Единственное, как быть если все такие компоненты разбросаны в разных частях сцены? Например, InventoryController, UnitSelectionController и т.д.?

alexprey, В таком случае, вы можете самостоятельно инициализировать экземпляры этих классов через инспектор. Например:
[SerializedField]
private InventoryController inventoryController;
А далее просто сделать общедоступный Get-метод для доступа к этому объекту, как вариант :)
Однако минус такого подхода в том, что при переходе между сценами, будут проблемы. В таком случае, нужно заранее продумать все детали.

DasBro, да действительно, все чуть проще чем я себе накрутил...

alexprey, В таком случае, вы можете самостоятельно инициализировать экземпляры этих классов через инспектор. Например:
[SerializedField]
private InventoryController inventoryController;
А далее просто сделать общедоступный Get-метод для доступа к этому объекту, как вариант :)
Однако минус такого подхода в том, что при переходе между сценами, будут проблемы. В таком случае, нужно заранее продумать все детали.
alexprey, Немного порассуждав над вопросом, я для себя понял следующее. Если у вас много таких классов, к которым можно обратится из любого класса, то вероятно, лучший способ - подумать над архитектурой т.к. рационально ли хранить, скажем на 10-20 игровых объектах разные важные глобальные данные? И второе. Если объектов таких несколько, в таком случае, может будет резонно сделать несколько классов-одиночек, причём тогда бы я разграничил всё по пространствам имён, чтобы класс N не имел сразу доступа ко всем одиночкам и классам внутри него, а брал только те данные, которые ему нужны..