
Использование ScriptableObjects в качестве каналов событий в игровом коде
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.
Как заставить разрозненные системы работать вместе? Одно из распространенных решений - использовать событие для отправки сообщений между объектами. Продолжайте читать, чтобы узнать, как использовать ScriptableObjects в качестве каналов событий в вашем проекте Unity.
Это пятое из серии шести мини-руководств, созданных для того, чтобы помочь разработчикам Unity в работе с демонстрацией, которая сопровождает электронную книгу, Создание модульной игровой архитектуры в Unity с помощью ScriptableObjects.
Демонстрация вдохновлена классической механикой аркадных игр с мячом и веслом и показывает, как ScriptableObjects может помочь вам создать компоненты, которые поддаются тестированию, масштабируются и удобны для дизайнеров.
Вместе электронная книга, демонстрационный проект и эти мини-руководства предоставляют лучшие практики использования паттернов программирования с классом ScriptableObject в вашем проекте Unity. Эти советы помогут вам упростить код, уменьшить потребление памяти и способствовать повторному использованию кода.
В эту серию вошли следующие статьи:
- Начните работу с демонстрацией Unity ScriptableObjects
- Разделите игровые данные и логику с помощью ScriptableObjects
- Использование перечислений на основе ScriptableObject в проекте Unity
- Использование объектов ScriptableObjects в качестве объектов делегатов
- Как использовать набор времени выполнения на основе ScriptableObject
Важное замечание перед началом работы
Прежде чем вы погрузитесь в демонстрационный проект ScriptableObject и эту серию мини-руководств, помните, что в своей основе паттерны проектирования - это всего лишь идеи. Они применимы не ко всем ситуациям. Эти приемы помогут вам освоить новые способы работы с Unity и ScriptableObjects.
У каждой модели есть свои плюсы и минусы. Выбирайте только те, которые приносят значительную пользу вашему конкретному проекту. Ваши дизайнеры в значительной степени полагаются на редактор Unity? Шаблон на основе ScriptableObject может стать хорошим выбором, чтобы помочь им сотрудничать с вашими разработчиками.
В конечном счете, лучшая архитектура кода - это та, которая подходит вашему проекту и команде.
Свободное взаимодействие, высокая сплоченность
При создании различных модулей или систем в приложении часто полезно думать о них как об "островках кода". В каждом модуле может быть несколько компонентов или объектов GameObject, которые работают вместе для достижения общей цели.
Например, весло игрока может содержать скрипт, который интерпретирует ввод игрока, скрипт, который обрабатывает движение или столкновения, и так далее. Если эти части взаимозависимы между собой, вы можете использовать Инспектор, чтобы установить эти тесные связи.
Однако не забывайте, что каждый раз, когда вы добавляете зависимость к другому объекту, это несет в себе небольшой риск. По возможности вы захотите свести к минимуму эти зависимости от внешних объектов. Связь с вещами, находящимися за пределами вашего модуля или системы, не будет такой прямой.
Вы можете сделать так, чтобы скрипт Paddle ссылался на Ball в вашей игре, но это означает, что у них есть связь. Когда они соединены зависимостью, изменение одного из них может потенциально повлиять на другой.
В идеале вы хотите иметь возможность изменять часть приложения, не нарушая при этом ничего другого. Цель состоит в том, чтобы ваши модули были внутренне целостными, но внешне разрозненными.
Вы можете использовать класс NullRefChecker проекта, чтобы выдать вежливое предупреждение, если в инспекторе отсутствуют необходимые ссылки. Просто вызовите статический метод Validate где-нибудь (например, в Awake) после установки или инициализации каждого компонента.
Добавьте пользовательский атрибут Optional, чтобы игнорировать проверку, если поле может быть не задано.
Использование событий
Как же заставить эти разрозненные системы работать вместе?
Одно из решений - использовать событие для отправки сообщений между объектами. События подчиняются модели "вещатель - слушатель", которая представлена на рисунке выше.
Здесь объект прослушивания подписывается на событие на вещателе, а не вызывает метод или ссылается на свойство напрямую.
Изменения, внесенные в один компонент, оказывают меньшее влияние на остальные. При изменении кода все еще могут возникнуть сбои, но объекты уже не будут так сильно переплетаться. Событие в центре работает как буфер между ними.
Мы часто называем объекты, находящиеся в таких отношениях "вещатель-слушатель", свободно связанными.
Подробнее о событиях и паттерне наблюдателя вы можете прочитать в нашей технической электронной книге Level up your code with game programming patterns.

ЦЕНТРАЛИЗОВАННАЯ СИСТЕМА СОБЫТИЙ
Централизованные мероприятия
В описанном выше сценарии вещатель отвечает только за передачу сигнала. Неважно, какие объекты прослушиваются.
Однако слушатель все равно должен иметь некоторое представление о вещателе, чтобы подписываться и отписываться от делегата с помощью методов OnEnable и OnDisable.
Вы можете еще больше разделить вещатель и слушатель, переместив события в статический класс. Общий класс "игровые события" может помочь вставить дополнительный уровень абстракции между ними. Это позволяет соединить вещателя и слушателя без их непосредственного знакомства друг с другом.
В этом примере для простоты мы будем использовать статический класс GameEvents. Однако в реальном производственном сценарии лучше разбить его на более мелкие специализированные классы по функциям, такие как UIEvents, GameStateEvents, HealthEvents, InventoryEvents и т. д.
Например, вы можете создать статические события для выхода из приложения, показа экрана пользовательского интерфейса или загрузки сцены. Благодаря тому, что эти события статичны, к ним можно получить доступ из любой части вашего приложения.
Например, вы можете создать GameEvents, как показано в примере ниже.
Статический GameEvent занимает промежуточное положение между оригинальным вещателем и слушателем. Изменения, вносимые как в получателя, так и в отправителя, с меньшей вероятностью могут повлиять на другого.
Следовательно, обновление кода имеет меньше неожиданных побочных эффектов. Хранение определений событий в одном месте также облегчает управление ими.
Хотя статические события GameEvents эффективны, они могут быть не очень доступны для ваших дизайнеров игр. Поскольку они статичны, их нужно определять в коде, и они не могут быть сериализованы в редакторе.
Чтобы сделать систему более удобной для редактора, рассмотрите возможность реализации событий на основе ScriptableObjects.
using UnityEngine;
using System;
public static class GameEvents
{
public static Action ExitApplication;
public static Action HomeScreenShown;
public static Action<float> LoadProgressUpdated;
}
СОБЫТИЙНЫЕ КАНАЛЫ ПЕРЕДАЮТ СИГНАЛЫ МЕЖДУ ВЕЩАТЕЛЯМИ И СЛУШАТЕЛЯМИ.
Настройка каналов событий
События на основе ScriptableObject предлагают графическую альтернативу статическим событиям. Хотя оба объекта выполняют схожие функции, ScriptableObjects, как правило, более удобны для дизайнера, поскольку отображаются в Инспекторе.
Поскольку они передают сигнал от вещателя к слушателю, их можно считать "каналами событий", что аналогично передаче с радиовышки.
Любой объект ScriptableObject с указанными ниже параметрами может выступать в качестве канала событий:
- Делегат (например, UnityAction или System.Action): Это уведомляет абонентов и передает данные в качестве параметров.
- Метод привлечения внимания к событиям: Этот публичный метод вызывает делегат.
Вы можете настроить любое количество каналов событий для определения различных аспектов игрового процесса.
UnityAction и System.Action являются делегатами. В своем проекте вы можете использовать один или оба типа.
UnityAction создает более удобные условия для художников. В противном случае используйте делегат System.Action.
Ниже приведен пример VoidEventChannelSO из проекта. Это событие на основе объекта ScriptableObject, которое не передает никаких параметров.
Здесь мы используем UnityAction с именем OnEventRaised и открываем публичный метод RaiseEventmethod.
using UnityEngine;
using UnityEngine.Events;
[CreateAssetMenu(menuName = "Events/Void Event Channel",
fileName = "VoidEventChannel")]
public class VoidEventChannelSO : DescriptionSO
{
[Tooltip("The action to perform")]
public UnityAction OnEventRaised;
public void RaiseEvent()
{
if (OnEventRaised != null)
OnEventRaised.Invoke();
}
}

СОЗДАЙТЕ КАНАЛЫ СОБЫТИЙ В ПРОЕКТЕ.
Создание активов канала событий
Создайте актив канала событий в проекте, чтобы использовать его. Вы можете воспользоваться меню "Создать" или продублировать существующий актив.
Переименуйте каждый актив и используйте поле описания для идентификации каждого актива ScriptableObject. Помните, что каждый канал событий существует как актив на уровне проекта. Вы будете ссылаться на эти активы в своих MonoBehaviours.
Хотя это и необязательно, вы можете пометить каналы событий, основанные на ScriptableObject, суффиксом _SO, чтобы отличить их от других ScriptableObject, несущих данные (которые имеют суффикс _Data).
Папки и соглашения об именовании помогут вашему проекту оставаться организованным. Вам нужно будет настроить их в соответствии с потребностями вашего проекта. Подробнее об этом читайте в статье Создание руководства по стилю C#.

НАЗНАЧЬТЕ КАНАЛ СОБЫТИЯ В ИНСПЕКТОРЕ.
Мероприятия по сбору средств
Теперь любой объект в вашей сцене может ссылаться на канал событий и вызывать событие с помощью метода RaiseEvent. Например, посмотрите на пример MonoBehaviour с методом TriggerEvent ниже.
В Инспекторе актив ScriptableObject должен быть назначен на поле m_EventChannel. Когда что-то вызывает TriggerEvent, событие исполняется. Все, что прослушивается, получает уведомление.
Этот механизм добавляет большую часть интерактивности в ваше игровое приложение. Каждый модуль или система вызывает событие (например, система ввода регистрирует нажатие клавиши, мяч сталкивается со стеной и т. д.). В ответ на это что-то другое реагирует на это.
public class EventRaiser: MonoBehaviour
{
[SerializeField]
private VoidEventChannelSO m_EventChannel;
public void TriggerEvent()
{
m_EventChannel.RaiseEvent();
}
}

ГЕЙММЕНЕДЖЕР СЛУШАЕТ ОДНИ КАНАЛЫ СОБЫТИЙ И ВЕЩАЕТ ПО ДРУГИМ.
Прослушивание событий
Чтобы установить слушателя, MonoBehaviour или другой компонент должен подписаться на событие OnEventRaised канала событий. Обычно это происходит в пунктеOnEnable, как в примере ниже.
Когда канал событий поднимает событие, в ответ запускается метод HandleEvent. Этот механизм может быть использован для различных целей, таких как воспроизведение звуков или эффектов, изменение настроек и т. д., в зависимости от контекста события.
В проекте PaddleBallSO именно так мы настраиваем основной цикл игры. GameManager прослушивает один набор каналов событий, а затем транслирует по другому. Это позволяет различным системам передавать друг другу сообщения, не имея при этом прямой зависимости.
Наконец, отпишитесь от события OnEventRaised в методе OnDisable, чтобы предотвратить ошибки или утечку памяти.
public class EventListener: MonoBehaviour
{
[SerializeField]
private VoidEventChannelSO m_EventChannel;
private void OnEnable()
{
m_EventChannel.OnEventRaised += HandleEvent;
}
private void OnDisable()
{
m_EventChannel.OnEventRaised -= HandleEvent;
}
private void HandleEvent()
{
Debug.Log("Event received");
}
}

ИНТЕРАКТИВНОСТЬ БЕЗ КОДА, НАСТРАИВАЕМАЯ В ИНСПЕКТОРЕ
Добавление слушателя без кода
Если вы работаете с дизайнерами, вы можете предоставить им предварительно настроенный скрипт общего назначения, который может прослушивать события. Это позволит им создавать игровые взаимодействия без участия программиста.
Примером может служить VoidEventChannelListener. Этот компонент поднимает событие UnityEvent, когда получает сигнал от канала событий. Просто добавьте VoidEventChannelListener к объекту GameObject, затем установите канал события и логику UnityEvent.
Дизайнер может создать прототип событийно-управляемой логики с помощью нескольких настроек в Инспекторе.
Например, префаб GameOverSounds прослушивает канал событий GameOver_SO. Получив это событие, он воспроизводит звук на заданном источнике AudioSource с помощью UnityEvent m_Response.
Класс VoidEventChannelListener также включает полезную задержку для настройки времени каждого ответа.
Немного практики - и вы сможете легко наладить взаимодействие между различными системами и модулями.

КАНАЛЫ СОБЫТИЙ, ОТМЕЧЕННЫЕ ДЛЯ ОТПРАВКИ И ПОЛУЧЕНИЯ
Как помогают каналы событий
Поскольку они существуют на уровне проекта, каналы событий доступны в глобальном масштабе. Это позволяет им подключать любой объект в иерархии сцены и сохранять его при загрузке сцены.
Любой объект может выступать в роли вещателя или слушателя - все дело в том, как он взаимодействует с каналом событий. Это дает вам большую свободу действий при отправке сообщений.
Примечание: Хорошая практика - указывать в инспекторе, для чего предназначен канал: для отправки или для приема. Для этого используйте атрибут HeaderAttribute.
Побочным преимуществом использования событий на уровне проекта является то, что они часто могут заменить необходимость в синглтоне. Каналы событий доступны во всем мире, поэтому они могут соединять что угодно с чем угодно. Пусть они управляют такими внутриигровыми системами, как камеры, квесты, здоровье и достижения - и все это без создания ненужных зависимостей.
Кроме того, поскольку архитектура, основанная на событиях, выполняется только тогда, когда это необходимо, она более оптимизирована, чем методы обновления MonoBehaviour.
Сигнатура функции базового события
Этот класс VoidEventChannelSO работает только для событий, которым не нужны никакие параметры. Часто для того, чтобы событие стало значимым, ему требуется дополнительный объем данных.
Например, если вы отправляете событие, наносящее урон в системе здоровья, вам может понадобиться передать значение цели, количество наносимого урона, тип урона и т. д.
Вы можете изменить сигнатуру функции вашего базового события, чтобы сделать канал событий более подходящим для этого. Для этого в проекте определен GenericEventChannelSO. Посмотрите на пример ниже.
Это абстрактный класс с одним общим параметром. Из него вы выведете другие каналы событий. В них можно передать один параметр, например float, int или bool.
Как и VoidEventChannelSO, GenericEventChannelSO имеет UnityAction под названием OnEventRaised. Однако на этот раз действие содержит параметр типа T.
Внешние объекты будут вызывать соответствующий публичный метод RaiseEvent. Если у события есть слушатели, то оно выполняется при передаче заданного параметра.
public abstract class GenericEventChannelSO<T>: DescriptionSO
{
public UnityAction<T> OnEventRaised;
public void RaiseEvent(T parameter)
{
if (OnEventRaised == null)
return;
OnEventRaised.Invoke(parameter);
}
}
Создание конкретных каналов событий
Теперь вам нужно только вывести конкретные каналы событий из GenericEventChannelSO и заполнить значение для T.
Помимо обычного атрибута CreateAssetMenu, нет необходимости в каких-либо явных деталях реализации.
Создание канала событий, передающего плавающие значения, FloatEventChannelSO, очень просто. Посмотрите на пример кода ниже.
Это так просто! Используйте этот рабочий процесс для создания дополнительных для BoolEventChannelSO, IntEventChannelSO и т.д.
Если вам требуется более одного параметра в качестве полезной нагрузки, определите дополнительные общие классы (например, GenericEventChannelSO<T,U>, GenericEventChannelSO<T,U,V> и т. д.), если это необходимо.
[CreateAssetMenu(menuName = "Events/Float EventChannel", fileName = "FloatEventChannel")]
public class FloatEventChannelSO : GenericEventChannelSO<float> {}

ПОСЛЕДОВАТЕЛЬНОСТЬ СОБЫТИЙ ПРИ ПОПАДАНИИ МЯЧА В ВОРОТА
Собираем все вместе
Идея заключается в том, чтобы разбить приложение на более мелкие, модульные части. Установление четких границ вокруг них не позволяет этим частям переплетаться с зависимостями и помогает избежать "спагетти-кода".
Компоненты, не имеющие прямого представления о внешних объектах, не могут манипулировать тем, что им не положено. Вместо этого они вынуждены отправлять и получать сообщения по каналам событий.
Вы можете увидеть, как это работает, если проследите небольшую последовательность игрового процесса с шариком. Например, давайте представим, что происходит, когда мяч сталкивается с целью:
Компонент ScoreGoal регистрирует столкновение. После обнаружения мяча он поднимает событие на канале событий GoalHit_SO. Передается идентификатор игрока, забившего гол.
Канал событий уведомляет GameManager, который в ответ поднимает другой канал событий под названием PointsScored_SO. При этом также передается идентификатор игрока.
Этот канал уведомляет ScoreManager, который увеличивает счет (хранящийся в отдельном объекте) и обновляет компоненты пользовательского интерфейса. Затем он передает результаты обоих игроков по каналу события ScoreManagerUpdated_SO.
В ответ на это цель ScoreObjective_SO проверяет, достиг ли один из игроков целевого показателя.
Если условие победы достигнуто, игра заканчивается. В противном случае GameManager сбрасывает раунд, и мяч возвращается в игру.
На первый взгляд может показаться, что увеличение значения оценки на один балл - это лишняя работа. Однако цель состоит в том, чтобы изолировать все части, участвующие в процессе: Мяч, ScoreManager, GameManager, ObjectiveManager и т.д.
Каждая часть приложения обладает определенной автономностью, и это облегчает тестирование каждой из них. Добавление новых систем не обязательно должно нарушать существующую логику. В оригинальном геймплее они могут быть совершенно незаметны.
Представьте, что вы хотите добавить вторичные эффекты, такие как звуки и анимация, чтобы сопровождать процесс подсчета очков. Вы можете создать новые компоненты, которые будут слушать нужные события и реагировать на них соответствующим образом. Логика и течение игры могут оставаться неизменными, даже если вы добавляете новые системы.
Помните, что мантра в программировании SOLID гласит: "Открыто для расширения, закрыто для модификации". Вы хотите иметь возможность добавлять новые функции в свое программное обеспечение без необходимости изменять существующий код. Подобное использование каналов событий обеспечивает масштабируемость.

СЦЕНАРИИ РЕДАКТОРА МОГУТ ПОМОЧЬ В ОТЛАДКЕ СОБЫТИЙ.
События отладки
Событийно-ориентированная архитектура облегчает отладку и обслуживание. Маленькие части легче тестировать, независимо от того, пишете ли вы автоматизированные модульные тесты с помощью Unity Test Framework или просто неформально устраняете неполадки. Это позволит вам сосредоточиться на конкретной проблеме и провести тестирование изолированно.
Здесь могут помочь пользовательские сценарии редактора. PaddleBallSO демонстрирует несколько инструментов, которые помогают отследить поток вашего приложения при использовании каналов событий:
- Большинство каналов событий в проекте PaddleBallSO отображают список слушателей в инспекторе. Щелкните имя каждого слушателя, чтобы выделить его в иерархии.
- Пользовательская кнопка RaiseEvent может вызывать имитируемое событие по своему усмотрению (используя значение по умолчанию T, если оно несет полезную нагрузку). Пока приложение работает, просто запустите его вручную одним щелчком мыши.
При устранении неполадок в каналах событий выберите актив ScriptableObject. При необходимости протестируйте событие вручную. Инспектор может подсказать вам, какие предметы можно прослушать. Выберите слушателей, которых вы хотите проверить более подробно.
Если вы пометили каналы событий с помощью HeaderAttritute, вы можете проследить несколько событий, чтобы понять логический поток.

Другие ресурсы ScriptableObject
Мы надеемся, что каналы событий и архитектура, ориентированная на события, принесут пользу вашим новым и будущим проектам.
Подробнее о паттернах проектирования с помощью ScriptableObjects читайте в нашей технической электронной книге, Создание модульной игровой архитектуры в Unity с помощью ScriptableObjects. Вы также можете узнать больше о распространенных паттернах разработки Unity в Повысьте уровень своего кода с помощью паттернов программирования игр.