Введение
В данной статье будут затронуты темы, связанные с реализацией возможности “мягкой связи” компонентов игровой логики на основе системы сообщений при разработке игр на Unity3D.
Ни для кого не секрет, что в подавляющем большинстве случаев средств, которые предоставляет движок в базовом виде, недостаточно для полноценной реализации систем обмена данными между компонентами игры. В самом примитивном варианте, с которого все начинают, мы получаем информацию через экземпляр объекта. Получить этот экземпляр можно разными способами от ссылки на объект сцены, до функций Find. Это не удобно, делает код не гибким и заставляет программиста предусматривать множество нестандартных поведений логики: от “объект исчез из сцены”, до “объект не активен”. Помимо прочего может страдать и скорость работы написанного кода.
Прежде, чем приступить к рассмотрению способов решения проблемы, остановимся подробнее на ее предпосылках и базовых терминах, которые будут упомянуты ниже. Начнем с того, что подразумевается под “мягкой связью”. В данной случае — это обмен данными между компонентами игровой логики таким образом, чтобы эти компоненты абсолютно ничего не знали друг о друге. Исходя из этого, любые ссылки на объекты сцены или же поиск объекта в сцене по имени или типу дают нам “жесткую связь”. Если эти связи начнут выстраиваться в цепочки, то в случае необходимости изменения поведения логики программисту придется все перенастраивать заново. Как не сложно догадаться, гибкостью здесь и не пахнет. Конечно, можно написать расширение редактора, которое будет автоматически заполнять ссылки, но это не решит другую проблему – покомпонентное тестирование игровой логики.
Для того, чтобы написанное выше было более понятно, рассмотрим простой пример. В логике нашей сферической игры есть несколько компонентов: оружие, враг и пуля. Произведя выстрел из оружия, мы должны получить следующую информацию: попала или нет пуля во врага, если попала, то сколько нанесла врагу урона, если нанесла урон, то умер враг или нет. Помимо этого, мы должны передать часть этой информацию другим компонентам, таким как графический интерфейс, который отобразит нам количество нанесенного урона, количество здоровья у врага и количество боеприпасов в оружии. Сюда же можно отнести отображение соответствующих эффектов выстрела, попадания, а также анимации и т.п… Не трудно представить количество взаимосвязей и передаваемых данных. Реализовать это на “жесткой связи” можно, однако, что будет, если нам нужно протестировать логику пули, если при этом у нас еще нет оружия и нет врагов или же протестировать логику работы интерфейса, но у нас нет при этом ни логики оружия, ни врагов, ни пули, или мы захотели заменить пулю на ракету. Именно решением подобной проблемы и продиктована необходимость создания систем “мягкой связи”, которая позволит нам с легкостью имитировать различные компоненты, даже если их еще не существует, а также менять их без изменения, связанного с ними кода.
Остановимся более подробно на базовом принципе реализации “мягкой связи”. Как было сказано выше, чтобы “мягко” связать два компонента, мы должны передать данные от одного к другому, так, чтобы они не знали ничего друг о друге. Для того, чтобы это обеспечить, нам нужно получить данные не по запросу (имея на руках экземпляр объекта), а использовать механизм уведомлений. Фактически это означает, что при наступлении каких-либо событий в объекте/компоненте, мы не спрашиваем этот объект о его состоянии, объект сам уведомляет о том, что в нем произошли изменения. Набор таких уведомлений формирует интерфейс (не путать с interface в C#), с помощью которого игровая логика получает данные о нашем объекте. Наглядно это можно представить следующим образом:
Таким образом любой компонент системы через интерфейс уведомлений может получить необходимые данные об объекте игровой логике, при этом наличие самого объекта для тестирования связанных с ним компонентов необязательно, нам достаточно реализовать интерфейс, а затем подменить его на рабочий экземпляр.
Рассмотрим более подробно способы реализации описанного выше механизма, а также их плюсы и минусы.
Система сообщений на основе UnityEvents/UnityAction
Данная система появилось сравнительно недавно (в 5 версии движка Unity3D). Пример того, как реализовать простую систему сообщений можно посмотреть по этой ссылке.
Плюсы использования данного способа:
- Встроенная в Unity возможность.
Минусы:
- Встроенная в Unity возможность (не всегда родные системы лучше).
- Не гибко из-за использования UnityAction (хотя это можно обойти), который дает ограничение на количество параметров (четыре максимум).
- Не гибко из-за сложностей с изменением параметров сообщения, так как придется во многих местах кода менять типы, обработчики и т.п..
- Непонятно зачем использовать при наличии в C# event/delegate.
Классическая система C# на Event/Delegate
Самый простой и достаточно эффективный способ реализации связи компонентов на основе уведомлений — это использование пары event/delegate, которая является частью языка C# (подробнее можно почитать в статьях на habr'е или msdn).
Есть много разных вариантов реализации системы сообщений на основе event/delegate, некоторые из них можно найти на просторах интернета. Я приведу пример, на мой взгляд, наиболее удобной системы, однако для начала хочу упомянуть об одной важной детали, связанной с использованием event'ов. Если у события (event) нет подписчиков, то при вызове этого события произойдет ошибка, поэтому перед использованием обязательна проверка на null. Это не совсем удобно. Конечно можно написать обертку для каждого event, где будет проводиться проверка на null, но это еще более не удобно. Перейдем к реализации.
Для начала определяем интерфейс сообщений для нашего объекта логики:
// Message Interface
public partial class GameLogicObject
{
public delegate void StartEventHandler();
public delegate void ChangeHealthEventHandler(int health);
public delegate void DeathEventHandler();
public static event StartEventHandler StartEvent;
public static event ChangeHealthEventHandler ChangeHealthEvent;
public static event DeathEventHandler DeathEvent;
}
Вызов уведомлений делается следующим образом (пример):
public partial class GameLogicObject : MonoBehaviour
{
public int Health = 100;
void Start()
{
if (StartEvent != null)
{
StartEvent();
}
StartCoroutine(ChangeHealth());
}
IEnumerator ChangeHealth()
{
yield return new WaitForSeconds(1f);
Health = Mathf.Clamp(Health - UnityEngine.Random.Range(1, 20), 0, 100);
if (ChangeHealthEvent != null)
{
ChangeHealthEvent(Health);
}
if (Health == 0)
{
if (DeathEvent != null)
{
DeathEvent();
}
}else
{
StartCoroutine(ChangeHealth());
}
}
}
Интерфейс и логика сформированы, теперь ничто не мешает нам использовать его в любом другом месте и подписаться на нужные уведомления:
public class GUILogic : MonoBehaviour
{
public Text HealthInfo;
public Text StateInfo;
void OnEnable()
{
GameLogicObject.StartEvent += GameLogicObject_StartEventHandler;
GameLogicObject.ChangeHealthEvent += GameLogicObject_ChangeHealthEventHandler;
GameLogicObject.DeathEvent += GameLogicObject_DeathEventHandler;
}
private void GameLogicObject_DeathEventHandler()
{
StateInfo.text = "Im died";
}
private void GameLogicObject_ChangeHealthEventHandler(int healthValue)
{
HealthInfo.text = healthValue.ToString();
}
private void GameLogicObject_StartEventHandler()
{
StateInfo.text = "Im going";
}
void OnDisable()
{
GameLogicObject.StartEvent -= GameLogicObject_StartEventHandler;
GameLogicObject.ChangeHealthEvent -= GameLogicObject_ChangeHealthEventHandler;
GameLogicObject.DeathEvent -= GameLogicObject_DeathEventHandler;
}
}
Как видно из примера, подписка происходит в методе OnEnable, а отписка в OnDisable и в данном случае она обязательна, иначе гарантирована утечка памяти и исключения по нулевой ссылке (null reference exception), если объект будет удален из игры. Саму подписку можно осуществлять в любой необходимый момент времени, это не обязательно делать только в OnEnable.
Легко заметить, что при таком подходе, мы можем без каких-либо проблем тестировать работу класса GUILogic, даже в отсутствии реальной логики GameLogicObject. Достаточно написать имитатор, использующий интерфейс уведомлений и использовать вызовы вида GameLogicObject.StartEvent ().
Какие плюсы дает нам данная реализация:
- Стандартный механизм языка C#, как итог 100% кроссплатформенность без танцев с бубнами.
- Простота реализации системы (без дополнительных вложений в код).
Минусы:
- Необходимо следить за памятью (отписка от уведомлений). При больших объемах кода, легко забыть отписаться от одного события и потом долго ловить баги.
- Необходимо отписываться от событий, если объект сцены деактивируется на время, в противном случае для него будет вызван обработчик.
- Не гибко из-за сложностей с изменением параметров сообщения, так как придется во многих местах кода менять типы, обработчики, вызовы и т.п..
- Необходимо учитывать, что событие может не иметь подписчиков.
Reflection система сообщений с идентификаций на string
Прежде чем приступить к описанию системы и ее реализации, хотелось бы остановиться на предпосылках, которые толкнули на ее создание. До прихода к этим мыслям в своих приложениях я использовал описанную выше систему на основе event/delegate. Те проекты, которые мне пришлось разрабатывать на тот момент, были относительно простые, в них требовалась скорость реализации, минимум багов на тестах, исключение по максимуму человеческого фактора в фазе разработки игровой логики. Исходя из этого, родился ряд некоторых требований относительно обмена данными между компонентами:
- Автоматическая подписка на события.
- Отсутствие необходимости следить за памятью (самоочистка системы).
- Отсутствие необходимости следить за подписчиками, система должна работать даже если их нет.
Итогом недолгих размышлений стало рождение идеи использовать рефлексию через атрибуты методов класса/компонента.
Идентифицируем метода класса как обработчик события:
[GlobalMessanger.MessageHandler]
void OnCustomEvent(int param)
{
}
GlobalMessanger.MessageHandler – атрибут, который указывает нам, что метод является обработчиком события. Для того, чтобы определить тип события, которое данный метод обрабатывает, существует два способа (хотя на самом деле может быть и больше):
- Указывать типа события в параметрах атрибута:
[GlobalMessanger.MessageHandler("CustomEvent")]
- Использовать тип события в имени метода с префиксом “On” (или любым другим). Я использую именно этот способ, поскольку в 100% случаях, чтобы не путаться именую методы именно так.
Для того, чтобы осуществить подписку в автоматическом режиме, опять же, существует два способа:
- Использовать скрипт, которые будет осуществлять поиск всех компонентов на объекте, а затем через рефлексию искать в них методы с атрибутом. Для того, чтобы не добавлять этот скрипт руками, достаточно будет во всех компонентах, где это потребуется, проставить
[RequireComponent(typeof(AutoSubsciber))]
Я лично считаю этот способ менее удобным, чем второй, поскольку требует лишних телодвижений.
- Создание обертки над классом MonoBehaviour:
CustomBehaviourpublic class CustomBehaviour : MonoBehaviour { private BindingFlags m_bingingFlags = BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy; protected void Subscribe(string methodName) { var method = this.GetType().GetMethod(methodName, m_bingingFlags); GlobalMessanger.Instance.RegisterMessageHandler(this, method); } protected void Unsubscribe(string methodName) { var method = this.GetType().GetMethod(methodName, m_bingingFlags); GlobalMessanger.Instance.UnregisterMessageHandler(this, method); } protected virtual void Awake() { var methods = this.GetType().GetMethods(m_bingingFlags); foreach(MethodInfo mi in methods) { if(mi.GetCustomAttributes(typeof(GlobalMessanger.MessageHandler), true).Length != 0) { GlobalMessanger.Instance.RegisterMessageHandler(this, mi); } } } }
Как видно, данная обертка содержит в себе два метода, которые позволяют подписываться и отписываться от события (тип события забирается из имени метода). Они необходимы в случае, если нам нужно осуществить подписку на событие по ходу работы логики. Автоматическая подписка осуществляется в методе Awake. Отписка от событий осуществляется автоматически, но об этом чуть позже.
Определяем систему менеджмента подписок и вызовов событий:
public class GlobalMessanger : MonoBehaviour
{
private static GlobalMessanger m_instance;
public static GlobalMessanger Instance
{
get
{
if(m_instance == null)
{
var go = new GameObject("!GlobalMessanger", typeof(GlobalMessanger));
m_instance = go.GetComponent<GlobalMessanger>();
}
return m_instance;
}
}
public class MessageHandler : Attribute { }
private class MessageHandlerData
{
public object Container;
public MethodInfo Method;
}
private Hashtable m_handlerHash = new Hashtable();
}
Класс GlobalMessanger является обычным компонентом Unity, доступ к которому осуществляется на основе Unity-синглетона. При этом, для этого компонента создается отдельный объект сцены, который существует только внутри нее и будет удален, когда сцена будет выгружена. Поскольку у нас события идентифицируются на основе строк, то информацию о событиях и подписчиках я решил хранить в хеш-таблице.
Теперь нам нужны методы регистрации/удаления подписчиков:
public void RegisterMessageHandler(object container, MethodInfo methodInfo)
{
var methodName = methodInfo.Name;
var messageID = methodName.Substring(2);
if (!m_handlerHash.ContainsKey(messageID))
{
RegisterMessageDefinition(messageID);
}
var messageHanlders = (List<MessageHandlerData>)m_handlerHash[messageID];
messageHanlders.Add(new MessageHandlerData() { Container = container, Method = methodInfo });
}
public void UnregisterMessageHandler(object container, MethodInfo methodInfo)
{
var methodName = methodInfo.Name;
var messageID = methodName.Substring(2);
if (m_handlerHash.ContainsKey(messageID))
{
var messageHanlders = (List<MessageHandlerData>)m_handlerHash[messageID];
for (var i = 0; i < messageHanlders.Count; i++)
{
var mhd = messageHanlders[i];
if (mhd.Container == container && mhd.Method == methodInfo)
{
messageHanlders.Remove(mhd);
return;
}
}
}
}
Далее нам нужен метод вызова событий и подписчиков на них:
public void Call(string messageID, object[] parameter = null)
{
if (m_handlerHash.ContainsKey(messageID))
{
var hanlderList = (List<MessageHandlerData>) m_handlerHash[messageID];
for(var i = 0; i < hanlderList.Count; i++)
{
var mhd = hanlderList[i];
var unityObject = (MonoBehaviour)mhd.Container;
if (unityObject != null)
{
if (unityObject.gameObject.activeSelf)
{
mhd.Method.Invoke(mhd.Container, parameter);
}
}
else
{
m_removedList.Add(mhd);
}
}
for (var i = 0; i < m_removedList.Count; i++)
{
hanlderList.Remove(m_removedList[i]);
}
m_removedList.Clear();
}
}
Как видно, при вызове события происходит проверка на существование объекта и на активность объекта. В первом случае, удаленный объект убирается из подписчиков, во втором же игнорируется при вызове методов обработки события. Таким образом, следить за отпиской событий у удаленного объекта не требуется, все осуществляется автоматически. При этом, если объект был временно деактивирован, то не осуществляется отписка от событий и повторная подписка, а также при вызове наличие подписчиков на событие не обязательно.
Последнее, что требуется от нас, это провести очистку по выгрузке сцены:
void OnDestroy()
{
foreach(var handlers in m_handlerHash.Values)
{
var messageHanlders = (List<MessageHandlerData>)handlers;
messageHanlders.Clear();
}
m_handlerHash.Clear();
m_handlerHash = null;
}
Легко заметить, что описанная выше система не представляет из себя ничего сверхординарного и не несет в себе откровений, однако она проста и удобна и хорошо подходит для относительно небольших проектов.
Пример использования данной системы:
// Message Interface
public partial class GameLogicObject
{
public static void StartEvent()
{
GlobalMessanger.Instance.Call("StartEvent");
}
public static void ChangeHealthEvent(int value)
{
GlobalMessanger.Instance.Call("ChangeHealthEvent", new object[] { value });
}
public static void DeathEvent()
{
GlobalMessanger.Instance.Call("DeathEvent");
}
}
// Event source
public partial class GameLogicObject : MonoBehaviour
{
public int Health = 100;
void Start()
{
StartEvent();
StartCoroutine(ChangeHealth());
}
IEnumerator ChangeHealth()
{
yield return new WaitForSeconds(1f);
Health = Mathf.Clamp(Health - UnityEngine.Random.Range(1, 20), 0, 100);
ChangeHealthEvent(Health);
if (Health == 0)
{
DeathEvent();
}else
{
StartCoroutine(ChangeHealth());
}
}
}
// Event subsciber
public class GUILogic : MonoBehaviour
{
public Text HealthInfo;
public Text StateInfo;
[GlobalMessanger.MessageHandler]
private void OnDeathEvent()
{
StateInfo.text = "Im died";
}
[GlobalMessanger.MessageHandler]
private void OnChangeHealthEvent(int healthValue)
{
HealthInfo.text = healthValue.ToString();
}
[GlobalMessanger.MessageHandler]
private void OnStartEvent()
{
StateInfo.text = "Im going";
}
}
Думаю, сразу заметно насколько сократился код по сравнению с event/delegate, что меня лично радует.
Какие плюсы дает нам данная реализация:
- Автоматическая подписка на события.
- Нет необходимости следить за отпиской от событий (даже в случае ручной подписки).
- Относительно простая реализация.
- Удобное чтение кода, по атрибутам легко видно, кто является обработчиком событий и каких именно.
- Меньший объем кода по сравнению с event/delegate
- Нет необходимости думать о том, что у события нет подписчиков.
Минусы:
- Поскольку отписка от событий происходит в отложенном режиме, на очень больших проектах, возможно это будет забирать лишнюю память, но это легко решаемо за счет ручной отписки событий, аналогично тому как это было показано в разделе про event/delegate.
- Привязка к строкам, так как если захочется сделать имя события более красивым, придется менять его во многих местах.
- Нет гибкости по параметрам и типам данных событий, изменения требует многих действий по коду.
- Возможны проблемы с кроссплатформенностью из-за использования рефлексии.
Reflection система сообщений с идентификаций на типах данных
В предыдущем разделе была описана система более удобная (на мой взгляд) по сравнению с event/delegate, однако она все также имеет ряд недостатков, которые сильно влияют на гибкость нашего кода, поэтому следующим шагом было ее развитие с учетом этих факторов.
Итак, нам нужно сохранить все плюсы предыдущей системы, но сделать ее гибче и более устойчивой к возможным изменениям в игровой логике. Поскольку основная проблема — это изменения имени события и передаваемых параметров, то возникла идея идентифицировать события именно по ним. Фактически это означает, что любое событие, которое возникает в компоненте характеризуется ничем иным, как данными, которые оно передает. Поскольку мы не можем просто привязаться к стандартным типам (int, float и т.п.), потому что в логике такие данные могут передавать многие компоненты, то логичным шагом было сделать обертку над ними, которая была бы удобной, легко читаемой и однозначно интерпретирующей событие.
Таким образом, наш интерфейс сообщений принял следующий вид:
public partial class GameObjectLogic
{
[GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.NotRequired)]
public sealed class StartEvent : GEvents.BaseEvent<StartEvent> { }
[GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.Required)]
public sealed class ChangeHealthEvent : GEvents.BaseEvent<ChangeHealthEvent>
{
public int Value { get; private set; }
public ChangeHealthEvent(int value)
{
Value = value;
}
}
[GEvents.EventDescription(HandlerRequirement = GEvents.HandlerRequirementType.Required)]
public sealed class DeathEvent : GEvents.BaseEvent<DeathEvent> { }
}
Как видно, у событий появились атрибуты. Это дает нам возможность получить отладочную информацию, в случае, если событие требует подписчика, а в коде его по каким-то причинам нет.
Метод вызова событий Call (и его перегрузки), который ранее у нас был частью класса GlobalMessanger, теперь является статическим и находится в GEvents.BaseEvent и принимает теперь в качестве параметра экземпляр класса, описывающего тип события.
Код вызова уведомлений теперь будет таким:
public partial class GameLogicObject : MonoBehaviour
{
public int Health = 100;
void Start()
{
StartEvent.Call(); //вызов уведомления о событии
StartCoroutine(ChangeHealth());
}
IEnumerator ChangeHealth()
{
yield return new WaitForSeconds(1f);
Health = Mathf.Clamp(Health - UnityEngine.Random.Range(1, 20), 0, 100);
ChangeHealthEvent.Call(new ChangeHealthEvent(Health)); //вызов уведомления о событии
if (Health == 0)
{
DeathEvent.Call(); //вызов уведомления о событии
}else
{
StartCoroutine(ChangeHealth());
}
}
}
Подписка и отписка на события, осуществляется тем же самым способом, что и ранее, через атрибуты методов, однако теперь идентификация типа события происходит не по строковому значению (имя метода или параметр атрибута), а по типу параметра метода (в примере это классы StartEvent, ChangeHealthEvent и DeathEvent).
Пример метода обработчика:
[GEvents.EventHandler]
public void OnDeathEventHandler(GameLogicObject.DeathEvent ev)
{
//выполняем действия связанные с событием
}
Таким образом, используя описанную выше реализацию, мы получили максимально возможную гибкость в коде, поскольку теперь мы можем как угодно менять передаваемые данные в событиях без значительных затрат, нам достаточно изменить тело обработчика под новые параметры. В случае же, если мы захотим изменить имя события (имя класса), компилятор скажет нам в каких местах кода используется старый вариант. При этом необходимость менять имя метода обработчика полностью отпадает.
Итог
Я постарался описать в этой статье все возможные на мой субъективный взгляд способы построения систем обмена данными между компонентами на основе уведомлений. Все эти способы были использованы мной в разных проектах и разной сложности: от простых мобильных проектов, до сложных PC. Какую систему использовать в вашем проекте – решать только вам.
PS: я намеренно не стал описывать в статье построение системы сообщения на основе SendMessage-функций, поскольку по сравнению с остальными она не выдерживает критики не только по удобству, но и по скорости работы.