Траектория «Геймдев» · линия A · конспект 10 из 13

Паттерны проектирования в Unity

О чём эта тема
Проверенные решения типовых проблем игрового кода: внедрение и инверсия зависимостей, фабрика, пул объектов и наблюдатель — с реализациями на C# и особенностями Unity.
Аннотация
Конспект начинается с понятий зависимости и её внедрения: классический пример с персонажем и оружием через интерфейс и конструктор, затем — почему в Unity этот путь не работает напрямую (запрет new для MonoBehaviour) и как внедряют зависимости через поля инспектора. Формулируется принцип инверсии зависимостей и идея инверсии контроля. Вторая часть — три рабочих паттерна: фабрика (централизованное создание объектов), пул объектов (переиспользование вместо Instantiate/Destroy — с симулятором, показывающим выигрыш) и наблюдатель (события и подписчики: делегаты, event и UnityEvent).
Пререквизиты
A07 — классы, методы, наследование, жизненный цикл (OnEnable/OnDisable); A08 — Instantiate, Destroy, префабы, SerializeField. Понятие интерфейса вводится здесь.
Мотивация
Скрипты из A08–A09 работают, но растущий проект начинает «слипаться»: скрипт меню знает про шар, шар — про надпись, всё ссылается на всё. Замена одного элемента тянет правки в десятке мест, а частые Instantiate/Destroy снарядов просаживают частоту кадров. Паттерны — это накопленные индустрией ответы именно на такие проблемы; четыре из них покрывают большинство нужд студенческого проекта.

1. Что такое паттерн проектирования

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

2. Зависимости и их внедрение

Два ключевых понятия. Зависимость (dependency) — объект, от которого зависит функциональность класса: персонаж зависит от оружия, чтобы атаковать врагов; зависимостями могут быть источники данных, другие классы, сервисы. Внедрение (injection) — процесс предоставления классу его зависимостей извне, вместо того чтобы класс создавал их сам.

Сравним. Без внедрения класс жёстко привязан к конкретному оружию:

public class Character
{
    private Sword sword = new Sword();   // зависимость создаётся внутри

    public void Attack()
    {
        sword.Swing();
    }
}

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

public interface IWeapon
{
    void Attack();
}

public class Sword : IWeapon
{
    public void Attack() => Console.WriteLine("Swing sword!");
}

public class Bow : IWeapon
{
    public void Attack() => Console.WriteLine("Shoot arrow!");
}

public class Character
{
    private IWeapon weapon;

    public Character(IWeapon weapon)     // внедрение через конструктор
    {
        this.weapon = weapon;
    }

    public void Attack() => weapon.Attack();
}

// сборка: оружие выбирается снаружи
IWeapon sword = new Sword();
Character character = new Character(sword);
character.Attack();

2.1. Особенность Unity: new запрещён

Напрямую перенести этот приём в Unity нельзя: объекты MonoBehaviour не создаются через new — этим типом классов Unity управляет сама, и конструкторы здесь не используются. Попытка new Player() для MonoBehaviour вызовет предупреждение движка. Объекты создаются через Instantiate(), но он принимает только префаб, позицию, поворот и родителя — передать через него ссылки-зависимости нельзя.

Поэтому в Unity зависимости внедряют иначе — чаще всего через поля, назначаемые в инспекторе. Пример: система очков для игры со сбором монет.

public interface IScoreManager
{
    void AddScore(int points);
    int GetScore();
}

// реализация — компонент на отдельном объекте-«контейнере»
public class ScoreManager : MonoBehaviour, IScoreManager
{
    private int score;

    public void AddScore(int points) => score += points;
    public int GetScore() => score;
}

public class Player : MonoBehaviour
{
    // зависимость назначается в инспекторе перетаскиванием объекта со ScoreManager
    [SerializeField] private ScoreManager scoreManager;

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Coin"))
        {
            scoreManager.AddScore(10);
            Destroy(other.gameObject);
        }
    }
}
Типичная ошибка Объявить поле типа интерфейса — public IScoreManager scoreManager; — и ждать его в инспекторе. Инспектор Unity не сериализует интерфейсные поля: поле просто не появится. Через инспектор внедряется конкретный компонент (как ScoreManager выше), а интерфейс остаётся типом для кода, который с этим компонентом работает.

2.2. Инверсия зависимостей и инверсия контроля

Принцип инверсии зависимостей: модули высокого уровня не должны зависеть от модулей низкого уровня — оба должны зависеть от абстракций; абстракции не должны зависеть от деталей — детали зависят от абстракций. В примере выше модуль высокого уровня — Character (логика: атаковать), модули низкого уровня — Sword, Bow (детали), абстракция — IWeapon. Character теперь зависит только от IWeapon, а конкретное оружие подставляется извне — это и есть внедрение зависимостей.

Рядом стоит идея инверсии контроля (IoC): управление потоком программы передаётся фреймворку или контейнеру, а не коду приложения. Житейский пример: старший разработчик раздаёт задачи младшим и контролирует выполнение — контроль «перевёрнут». Unity сама выступает таким фреймворком: она вызывает методы жизненного цикла ваших скриптов и помогает управлять зависимостями через инспектор.

3. Фабрика

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

Простая фабрика врагов из префаба:

using UnityEngine;

public interface ISpawnable
{
    void Initialize(object config);
}

public class Enemy : MonoBehaviour, ISpawnable
{
    public void Initialize(object config)
    {
        // настройка врага по config
    }
}

public class PrefabFactory : MonoBehaviour
{
    [SerializeField] private GameObject enemyPrefab;

    // создаёт объект в сцене и возвращает компонент ISpawnable
    public ISpawnable CreateEnemy(Vector3 position, object config = null)
    {
        var go = Instantiate(enemyPrefab, position, Quaternion.identity);
        var spawnable = go.GetComponent<ISpawnable>();
        spawnable?.Initialize(config);
        return spawnable;
    }

    // уничтожение через фабрику (можно заменить на возврат в пул)
    public void Destroy(GameObject go)
    {
        Destroy(go);
    }
}

Использование: компонент-спаунер получает ссылку на PrefabFactory и вызывает CreateEnemy(). Все места создания врагов сходятся в одну точку — менять префаб, вести учёт или добавить пул можно, не трогая спаунеры.

4. Пул объектов (Object Pool)

Идея: частые Instantiate и Destroy резко просаживают производительность. Пул решает проблему так: нужное количество экземпляров создаётся один раз при старте и хранится в неактивном состоянии; когда объект нужен — он активируется, когда больше не нужен — деактивируется и возвращается в пул вместо уничтожения. Если объектов не хватило, пул можно расширить, переиспользовать малозаметный активный объект или просто пропустить создание (для необязательных эффектов).

Классический простой пул пуль на списке:

using System.Collections.Generic;
using UnityEngine;

public class BulletPool : MonoBehaviour
{
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private int poolSize = 20;

    private List<GameObject> pool = new List<GameObject>();

    void Awake()
    {
        // создаём пули один раз при старте
        for (int i = 0; i < poolSize; i++)
        {
            var bullet = Instantiate(bulletPrefab);
            bullet.SetActive(false);
            pool.Add(bullet);
        }
    }

    public GameObject GetBullet(Vector3 position, Quaternion rotation)
    {
        foreach (var bullet in pool)
        {
            if (!bullet.activeInHierarchy)
            {
                bullet.transform.SetPositionAndRotation(position, rotation);
                bullet.SetActive(true);
                return bullet;
            }
        }

        // если пул пуст — создаём дополнительную пулю
        var newBullet = Instantiate(bulletPrefab, position, rotation);
        pool.Add(newBullet);
        return newBullet;
    }

    public void ReturnBullet(GameObject bullet)
    {
        bullet.SetActive(false);
    }
}

Вариант на списке подходит для небольшого числа объектов (до сотен): при выдаче перебираются все элементы, и на больших пулах поиск первого неактивного становится медленным.

Тренажёр · пул против Instantiate/Destroy

Каждая клетка — снаряд. Стреляйте одиночными или очередью: с выключенным пулом каждый выстрел — новый Instantiate (красная вспышка) и Destroy через секунду; с включённым — снаряды переиспользуются (розовые — активные, серые — ждут в пуле). Сравните счётчик Instantiate в обоих режимах после десятка очередей.

Instantiate0
переиспользовано0
Destroy0

4.1. Встроенный пул Unity

Начиная с Unity 2021 есть встроенная реализация — UnityEngine.Pool.ObjectPool<T>. Жизненный цикл объектов (создание, активация, деактивация, уничтожение) задаётся лямбда-функциями при инициализации; Unity сама контролирует размер пула и не создаёт объекты сверх maxSize, а операции выполняются без ручного поиска:

using UnityEngine;
using UnityEngine.Pool;

public class Bullet : MonoBehaviour
{
    private ObjectPool<Bullet> pool;

    public void SetPool(ObjectPool<Bullet> poolRef)
    {
        pool = poolRef;
    }

    void OnCollisionEnter(Collision collision)
    {
        pool.Release(this);          // возврат пули в пул после столкновения
    }
}

public class BulletSpawner : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;

    private ObjectPool<Bullet> bulletPool;

    void Awake()
    {
        bulletPool = new ObjectPool<Bullet>(
            createFunc: () =>
            {
                var b = Instantiate(bulletPrefab);
                b.SetPool(bulletPool);
                return b;
            },
            actionOnGet:     b => b.gameObject.SetActive(true),
            actionOnRelease: b => b.gameObject.SetActive(false),
            actionOnDestroy: b => Destroy(b.gameObject),
            defaultCapacity: 20,
            maxSize: 100
        );
    }

    public void Fire(Vector3 pos, Quaternion rot)
    {
        var bullet = bulletPool.Get();
        bullet.transform.SetPositionAndRotation(pos, rot);
    }
}

Практические советы: ссылки на префабы и фабрики храните в инспекторе; комбинация «фабрика + пул» почти всегда улучшает производительность при частом создании снарядов, частиц и врагов; для сложных объектов фабрика может собирать объект по частям — создавать корневой GameObject и добавлять компоненты.

5. Наблюдатель

Идея: организовать систему уведомлений — когда в игре происходит событие (враг погиб, игрок стал невидимым), другие объекты автоматически получают сигнал и реагируют: обновляют счёт, проигрывают анимацию, создают эффект. Паттерн разделяет (decouple) объекты: они не вызывают методы друг друга напрямую. В C# и Unity есть несколько инструментов: делегаты и event, Action/EventHandler, UnityEvent.

Издатель события — способность невидимости игрока:

using UnityEngine;

public class PlayerManagment : MonoBehaviour
{
    // делегат делает «вид функции» типом данных: какие подписчики допустимы
    public delegate void Invisibility();

    // событие этого типа; на него подписываются другие скрипты
    public static event Invisibility OnActivated;

    private bool _isActive = true;

    void Update()
    {
        if (_isActive && Input.GetKeyDown(KeyCode.Space))
        {
            OnActivated?.Invoke();   // «?.» — сигнал уходит, только если есть подписчики
            _isActive = false;
        }
    }
}

Подписчик — враг, который при активации невидимости теряет игрока из виду:

using UnityEngine;

public class Enemy : MonoBehaviour
{
    private bool isFollowing;

    private void OnEnable()
    {
        PlayerManagment.OnActivated += StopFollowing;   // подписка на событие
    }

    private void OnDisable()
    {
        PlayerManagment.OnActivated -= StopFollowing;   // отписка — обязательно
    }

    private void StopFollowing()
    {
        isFollowing = false;
        // дальше — своя реакция: искать в кустах, стрелять наугад…
    }
}

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

Типичная ошибка Подписаться в OnEnable и не отписаться в OnDisable. Уничтоженный объект остаётся в списке подписчиков статического события: сигнал уходит «в никуда», появляются ошибки MissingReferenceException и утечки. Правило: каждая подписка «+=» обязана иметь парную отписку «−=».

Вариант без кода подписки — UnityEvent: реакции подключаются прямо в инспекторе, как OnClick() у кнопки в A08. Подходит для визуальных эффектов, звуков и UI, где гибкость важнее производительности:

using UnityEngine;
using UnityEngine.Events;

public class EnemyWithUnityEvent : MonoBehaviour
{
    [SerializeField] private UnityEvent onDeath;   // настраивается в инспекторе

    void Die()
    {
        // ...логика смерти
        onDeath.Invoke();   // вызывает все методы, привязанные в инспекторе
    }
}

Контрольные вопросы

Источники

  1. Гамма, Э. Приёмы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма, Р. Хелм, Р. Джонсон, Дж. Влиссидес ; пер. с англ. — Санкт-Петербург : Питер, 2020. — 368 с.
  2. Nystrom, R. Game Programming Patterns / R. Nystrom. — [Электронный ресурс]. — URL: https://gameprogrammingpatterns.com (дата обращения: 08.07.2026).
  3. ObjectPool<T0> // Unity Scripting API : [сайт]. — URL: https://docs.unity3d.com/ScriptReference/Pool.ObjectPool_1.html (дата обращения: 08.07.2026).
  4. UnityEvent // Unity Scripting API : [сайт]. — URL: https://docs.unity3d.com/ScriptReference/Events.UnityEvent.html (дата обращения: 08.07.2026).