Паттерн: объекты-делегаты
Паттерн: объекты-делегаты
В Unity объекты ScriptableObject предназначены не только для хранения данных. Они могут содержать методы, а значит, объединять сведения о том, что нужно сделать (логику), и о том, с чем работать (данные).
Делегаты и события Делегаты и события в C# тесно связаны, но решают разные задачи. Делегат представляет собой тип, определяющий сигнатуру метода. Благодаря этому методы можно передавать другим методам в качестве аргументов. Делегат можно представить как переменную, которая вместо значения хранит ссылку на метод.
Событие, в свою очередь, представляет собой особый вид делегата, который позволяет классам взаимодействовать при слабой связанности. Подробнее события рассматриваются в главе о паттерне «Наблюдатель». Общие сведения см. в материале «Различия между делегатами и событиями в C#».
Суть подхода в том, чтобы инкапсулировать алгоритмы выполнения отдельных задач в самостоятельных объектах. Авторы книги «Банда четырех» называют такое общее решение паттерном «Стратегия». Предположим, объект поиска пути должен построить маршрут через лабиринт. Сам объект не содержит алгоритма поиска, а лишь хранит ссылку на другой объект, который выполняет вычисления.
Чтобы решить лабиринт конкретным алгоритмом поиска пути, например A* или алгоритмом Дейкстры, реализуйте это решение в отдельном объекте-стратегии. Во время выполнения алгоритм можно заменить, просто назначив другой объект.
Методы ScriptableObject В Unity этот паттерн можно реализовать с помощью MonoBehaviour, который ссылается на ScriptableObject с необходимой логикой. Выполняя задачу, MonoBehaviour вызывает внешние методы ScriptableObject вместо собственных. Однако у такого подхода есть несколько ограничений: - Методы ScriptableObject не вызываются автоматически из цикла PlayerLoop компонента MonoBehaviour, как Start(), Update() и OnCollisionEnter(). Их необходимо вызывать самостоятельно. - Как и префабы, объекты ScriptableObject не могут напрямую ссылаться на объекты сцены. Если ScriptableObject должен работать с объектом сцены, этот объект необходимо передать в качестве параметра. При вызове метода ScriptableObject компонент MonoBehaviour часто может передать в качестве аргумента самого себя или другие зависимости. Это позволяет выполнять логику, запускать корутины и решать другие задачи, хотя ScriptableObject существует на уровне проекта.
MonoBehaviour ссылается на ScriptableObject
ScriptableObject содержит методы и логику
ScriptableObject может содержать взаимозаменяемые реализации поведения или логики.
Например, в игре можно создать несколько типов противников с разным поведением при перемещении. Одни будут патрулировать территорию, другие стоять на месте, а третьи убегать от игрока. Один MonoBehaviour EnemyUnit может ссылаться на ScriptableObject EnemyAI с методом MoveUnit. Сам скрипт EnemyUnit не содержит логики перемещения или поведения, а лишь вызывает MoveUnit объекта ScriptableObject в подходящий момент. Если методу нужны данные сцены, EnemyUnit может передать ссылку на самого себя в качестве параметра. Таким же образом передаются и другие необходимые зависимости из сцены.
Изменение данных ScriptableObject Данные ScriptableObject можно изменять во время выполнения, но делать это следует осторожно. Если несколько компонентов MonoBehaviour совместно используют один ScriptableObject и изменяют одни и те же данные, могут возникнуть проблемы. Чтобы избежать этой ситуации, во время выполнения можно создать отдельный экземпляр ScriptableObject. Исходный ScriptableObject послужит шаблоном со всей логикой и данными, а каждый MonoBehaviour создаст собственный экземпляр, который можно свободно изменять.
Подключаемое поведение Этот паттерн станет полезнее, если определить ScriptableObject EnemyAI как абстрактный класс. Тогда он послужит шаблоном для разных объектов ScriptableObject, совместимых с MonoBehaviour EnemyUnit, а один абстрактный ScriptableObject сможет представлять несколько алгоритмов.
MonoBehaviour ссылается на ScriptableObject
Базовый ScriptableObject (абстрактный)
Производные классы ScriptableObject (конкретные)
Замена во время выполнения
Подключаемое поведение можно менять во время выполнения или в редакторе Unity.
Например, от базового EnemyAI можно унаследовать конкретные классы ScriptableObject с поведением Patrol, Idle и Flee. Все они реализуют один метод MoveUnit, но результаты его работы могут существенно различаться. В редакторе Unity каждый такой ассет можно заменить другим. Достаточно перетащить нужный ScriptableObject в поле EnemyAI. Любой совместимый ScriptableObject подключается таким способом.
EnemyUnit или другой компонент может играть роль «мозга»: отслеживать момент смены ScriptableObject и заменять поведение во время выполнения. Так EnemyUnit способен реагировать на игровые события, например переходить от патрулирования к бегству. При каждой смене состояния достаточно назначить другой ScriptableObject EnemyAI.
В рабочем проекте второй разработчик или дизайнер может реализовать перемещение или логику ИИ непосредственно в ScriptableObject. При добавлении новых вариантов перемещения и поведения, например DuckAndCover или Chase, исходный скрипт EnemyUnit останется без изменений. Такой паттерн повышает расширяемость кодовой базы и соответствует принципу открытости/закрытости из SOLID. Поскольку система уже разделена на небольшие объекты, проект легче масштабировать при расширении команды или изменении дизайна игры.
Игровой ИИ на основе ScriptableObject Более подробный пример управления поведением с помощью ScriptableObject представлен в серии видео «Подключаемый ИИ с ScriptableObject». В этих записях прямых эфиров показана система ИИ на основе конечного автомата, где состояния, действия и переходы между состояниями настраиваются с помощью объектов ScriptableObject.
Пример: аудиоделегаты Поведение в ScriptableObject не обязательно должно быть сложным. Это может быть даже обычное воспроизведение настроенного звука. Например, ScriptableObject в роли «звукового делегата» позволяет разнообразить звучание объектов AudioClip. AudioDelegateSO определяет абстрактный класс с методом Play, который принимает AudioSource в качестве параметра.
using UnityEngine;
using Random = UnityEngine.Random;
using System;
[Serializable] public struct RangedFloat {
public float MinValue;
public float MaxValue;
}
public abstract class AudioDelegateSO: ScriptableObject {
public abstract void Play(AudioSource source);
}Конкретный ScriptableObject SimpleAudioDelegate может выбирать случайный клип из доступных вариантов и изменять при воспроизведении его громкость и высоту тона. Это делает повторяющиеся звуки менее однообразными.
[CreateAssetMenu(fileName ="AudioDelegate")] public class SimpleAudioDelegateSO : AudioDelegateSO {
public AudioClip[] Clips;
public RangedFloat Volume;
public RangedFloat Pitch;
public void Play(AudioSource source) {if (clips.Length == 0 || source == null)
return;source.clip = clips[Random.Range(0, Clips.Length)];
source.volume = Random.Range(Volume.minValue, Volume.maxValue);
source.pitch = Random.Range(Pitch.minValue, Pitch.maxValue);
source.Play();
}
}После этого любой MonoBehaviour сможет использовать экземпляр ScriptableObject, унаследованный от AudioDelegateSO. Для разных звуковых эффектов можно создавать и другие варианты AudioDelegate. Методы ScriptableObject открывают множество возможностей. Они могут не только выполнять действия, но и отправлять сообщения любому объекту сцены. Теперь рассмотрим систему событий на основе ScriptableObject и паттерна «Наблюдатель».
Славная революция ScriptableObject Доклад Ричарда Файна «Свержение тирании MonoBehaviour в славной революции ScriptableObject» на Unite 2016 заложил основу значительной части этой электронной книги. Фрагмент демонстрационного проекта, который в оригинале назывался AudioEvent, был изменен для данного примера. Подробности реализации и пример применения ScriptableObject см. в примере проекта.