СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

| сохранено

H Простая система событий в Unity в черновиках Из песочницы

image

Что это такое, и для чего это нужно?


Рано или поздно любой проект Unity разрастается большим количеством скриптов и становится трудно держать в голове, какой скрипт с каким связан. С такой проблемой столкнулся и я. Через некоторое время вышел на публикацию «Методы организации взаимодействия между скриптами в Unity3D „. Меня сразу заинтересовал третий подход “Мировой эфир» (настоятельно рекомендую почитать, отлично показано, зачем это нужно). Он идеально мне подходит, но в той статье указан сложный вариант и я, не обладая большими знаниями программирования, так и не смог понять его. В комментариях заметил упоминания встроенной в язык системы событий. Погуглив, нашел статью про события в C#. В этом посте я хочу рассказать, как подружить unity и систему событий C#, чтобы уберечь форумы и unity answers от похожих вопросов.

Суть


Пусть у нас есть игра — шутер. При нажатии на кнопку прицеливания игрок переходит на шаг, включается камера прицела, выполняется сотня других действий. А при нажатии кнопки стрельбы — создание пули, просчет полета и т.д. Все это выльется в огромную кашу с кучей связей и программировать это все станет очень тяжело (см. картинку в первой статье).
Другой важный момент — для разных платформ управление будет сильно различаться и придется править кучу скриптов, содержащих в себе ввод.

События предлагают куда более элегантный и удобный подход, см. картинку выше. Схема приобретет такой вид:

image

InputAggregator слушает ввод и как только игрок нажал клавишу/тапнул, говорит в эфир: «Игрок нажал кнопку Х!». Об этом сразу узнают скрипты, подписанные на это событие (а точнее методы в этих скриптах) и выполняются указанные методы. Причем эти методы могут вызываться разными событиями, например, метод «Умереть» вызывается как при получении критического урона, так и, к примеру, при выходу за границы уровня.

Обратите внимание, что нам не важно, какую клавишу нажал игрок, важно лишь то, что произошло событие. Т.е. для подгонки управления для другой платформы нам не надо лезть в 10 скриптов и все их править, достаточно будет модифицировать InputAggregator.

Зачем нужен EventController — смотрите ниже.

Как это реализовать?


Забудем про шутер и пули, давайте поставим себе простую задачу:

Есть две сферы:

image

Мы хотим при нажатии кнопки Space сдвинуть левую сферу вверх, а нижнюю — вниз. И если какая-то из них уходит слишком далеко, вернуть их обратно.

Но для начала сделаем только первую часть, без преодоления границ:

image

Перво — наперво создадим скрипты сфер, содержащие методы телепортов и повесим их на сферы:

public class Sphere1T : MonoBehaviour 
{
    public void TeleportUp()
    {
        transform.Translate(Vector3.up);
    }
}


Аналогично для другой сферы.

Создадим скрипт EventController и повесим его куда-нибудь (я повесил на камеру).
В нем создадим делегат MethodContainer():

public delegate void MethodContainer();

Если очень грубо, делегаты — это указатели на методы или контейнеры методов. Для событий делегат используется как тип и с ним самим ничего делать не надо, поэтому особо вникать в механику делегатов необязательно (но знания лишними не бывают). Важно только знать, что к делегату подходят только методы, соответствующие его сигнатуре. В нашем случае это методы, не возвращающие значений (void) и не имеющие параметров (). Соответственно и методы, вызываемые нашими событиями, должны иметь такой вид.

Ну и создадим в нем линки на наши сферы, в редакторе не забываем указать, какой линк к чему относится (вы должны уже уметь это делать, если читаете эту статью), они нам понадобятся.

public Sphere1T s1t;
public Sphere2T s2t;


Теперь создадим скрипт InputAggregator. Создадим в нем событие типа MethodContainer, вызывающее телепорт сфер:

public static event EventController.MethodContainer OnTeleportEvent;

Т.е. наш делегат мы указываем в качестве типа. Обратите внимание, что создавать линк на EventController не нужно.
В методе Update() вызовем наше событие:

void Update()
    {
        if (Input.GetKeyDown("space")) OnTeleportEvent();
    }

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

Вернемся в EventController и в методе Awake() (вызывается при старте игры, похож на Start()) подпишем на наше событие методы TeleportUp() и TeleportDown(). Делается это при помощи операции инкремента, мы как бы прибавляем к событию методы:

void Awake()
    {
        InputAggregator.OnTeleportEvent += s1t.TeleportUp; //Обратите внимание, линк на класс (скрипт), содержащий событие, делать не нужно!
        InputAggregator.OnTeleportEvent += s2t.TeleportDown;
    }

Готово! Теперь при нажатии пробела вызывается событие OnTeleportEvent, которое вызывает два метода в скриптах на сферах.

Теперь попробуем сделать обработку границ.

В скрипты обеих сфер добавим событие выхода за границы (по одному на каждую):

 public static event EventController.MethodContainer OnAbroadLeft;

 public static event EventController.MethodContainer OnAbroadRight;

В методах телепортов вызываем событие:

if (transform.position.y > 3) OnAbroadLeft(); //Для правой < -3

И добавим методы телепорта назад (можно назвать одними именами):

public void ResetPosit()
    {
        transform.position = new Vector3(-2, 0, 0); //Для правой сферы (2,0,0)
    }


А в Awake() EventController'a подписываемся:

Sphere1T.OnAbroadLeft += s1t.ResetPosit;
Sphere2T.OnAbroadRight += s2t.ResetPosit;

Все, теперь наши сферы исправно двигаются и не убегают далеко.

Еще раз, полный код всех скриптов:

public class InputAggregator : MonoBehaviour 
{
    public static event EventController.MethodContainer OnTeleportEvent;

    void Update()
    {
        if (Input.GetKeyDown("space")) OnTeleportEvent();
    }
}


public class EventController : MonoBehaviour
{
    public delegate void MethodContainer();

    public Sphere1T s1t;
    public Sphere2T s2t;
    
    void Awake()
    {
        InputAggregator.OnTeleportEvent += s1t.TeleportUp;
        InputAggregator.OnTeleportEvent += s2t.TeleportDown;

        Sphere1T.OnAbroadLeft += s1t.ResetPosit;
        Sphere2T.OnAbroadRight += s2t.ResetPosit;
    }
    
    
}


public class Sphere1T : MonoBehaviour 
{
    public static event EventController.MethodContainer OnAbroadLeft;

    public void TeleportUp()
    {
        transform.Translate(Vector3.up);

        if (transform.position.y > 3) OnAbroadLeft();
    }

    public void ResetPosit()
    {
        transform.position = new Vector3(-2, 0, 0); 
    }
}


public class Sphere2T : MonoBehaviour
{
    public static event EventController.MethodContainer OnAbroadRight;

    public void TeleportDown()
    {
        transform.Translate(Vector3.down);

        if (transform.position.y < -3) OnAbroadRight();
    }

    public void ResetPosit()
    {
        transform.position = new Vector3(2, 0, 0); 
    }
}


Заключение
Это был простейший пример. Разумеется, для двух сфер воротить события абсолютно не нужно, но когда у вас в проекте больше двадцати скриптов, события приходят на помощь. Вот как мне, например:
        InputAggregator_script.OnTrajectoryCall_event += trajectoryScript.DrawTrajectory;
        InputAggregator_script.OnTrajectoryCall_event += destinationGUIScript.DrawDestinationText;
        InputAggregator_script.OnTrajectoryCall_event += playerMovScript.RestartMov;

        InputAggregator_script.OnTrajectoryClean_event += trajectoryScript.CleanTrajectory;
        InputAggregator_script.OnTrajectoryClean_event += destinationGUIScript.DisableDestinationText;

        InputAggregator_script.OnTurnSwitch_event += WorldState_class.ChangeDate;
        InputAggregator_script.OnTurnSwitch_event += playerMovScript.Mov;
        InputAggregator_script.OnTurnSwitch_event += dateGUIScript.UpdateDateBar;

Это только начало, представляете, как было бы сложно делать подобное для десятков скриптов без событий? А тут все аккуратно, видно, что на что подписано.

Надеюсь, статья была полезной, буду рад комментариям и замечаниям. Большое спасибо авторам двух указанных мною в начале статей, без них я сам бы не разобрался.

Удачи вам в ваших проектах!

P.S. Небольшое дополнение
У вас наверняка возник вопрос, как вызвать событие из другого скрипта, ведь если попытаться это сделать банальным скрипт.событие(); компилятор выдаст ошибку. Это ограничение можно обойти, если в скрипте, в котором описано событие создать метод, вызывающий это событие, например
 public void CallOnTurnSwitchEvent() { OnTurnSwitch_event(); }

И тогда вызвать это событие из другого класса можно банальным
public InputAggregator link;

void SomeMethod()
{
     link.CallOnTurnSwitchEvent();
}


Немного массивно по синтаксису, но неудобств почти не добавляет.
+8
8659

комментарии (15)

+4
HurrTheDurr ,  
Это возможность самого C#, а не Unity.
К тому же, ваш пример упадет, если на событие никто не подписался — перед вызовом, нужно проверять событие на null.
И содержит утечку памяти — от событий нужно отписываться при уничтожении объекта. Подписывайтесь в OnEnable, отписывайтесь в OnDisable.
+1
ArXen42 ,  
Спасибо за советы, завтра добавлю их в статью.
Моей задачей было конкретно показать, как скрестить стандартную си шарповскую систему и юнити, а если хочется самописную — статья про «мировой эфир» в помощь. Я расписал именно простейший вариант для новичков.
Кстати, а в JS что-нибудь подобное есть?
+2
+3 –1
Newarray ,  
Когда прочел статью сразу масса критики возникла, потом посмотрел автору топика только 17 лет — отупстило :)

И все же о некоторых пунктах я упомяну:
1.
Делается это при помощи операции инкремента, мы как бы прибавляем к событию методы


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

2.
Как правильно заметил HurrTheDurr казалось бы причем здесь Unity? События это стандартные возможности C#.

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

4.
Впринципе вы описали обычную MVC… Но если вы сами до этого дошли, то честь вам и хвала:)

5.
Продолжайте! :)
0
Newarray ,  
P.S.
Статья впринципе элементарная на RSDN более подробно описана тема событий. Но это первая публикаци автора, так что… Продолжайте! Буду рад следить за вашим успехами:)
+1
ArXen42 ,   * (был изменён)
1)Всегда думал, что инкремент на единицу — частный случай инкремента, ошибался, спасибо.
2)Большинство новичков начинают изучать С# когда приходят в Unity, а не наоборот. Соответственно ни о каких таких событиях они не слышали. Лично мне было очень сложно во все это въехать, даже сложнее чем когда-то в рекурсию. Уже почти бросил проект в третий раз, когда нашел удобоваримую статью про события и заставил работать этот пример со сферами. Причем, как только сделал, понял, что ничего сложного тут нет, но разбираться было тяжко. Вот и захотелось облегчить задачу другим.
3)Тоже думал на эту тему. Можно было бы объединить скрипты сфер и сделать одно событие под границы, но это усложнило бы восприятие событий, поэтому сделал все разделенным. Пожалуй, оформлю это все в качестве примечания.
4)Ну разве что создал один вопрос на unity answers и тему на форуме unity3d. В первом случае получил стену кода, во втором случае меня послали думать самому, а не ждать помощи, за что им спасибо, свой код всегда лучше чужого (хотя нет, он лучше чужого только тем, что досконально понятно, как он работает).
5)Обязательно! Пару месяцев попрограммирую, наверняка будет о чем рассказать. В следующий раз постараюсь быть более грамотным в рассматриваемом вопросе.
+1
fstleo ,  
Хорошо, но подписывать на события в самом контроллере кажется неудобным. Если объект создается и уничтожаются во время игры? Считаю, что лучше подписываться и отписываться на события в самом объекте, а не в контроллере.
Но статья будет полезна начинающим.
0
ArXen42 ,  
Зато так я показал, что на события можно подписываться из стороннего класса без создания его экземпляра (хотя если создавать экземпляр скрипта и привязывать его через редактор, то это, я так понимаю, не совсем экземпляр. Могу предположить, что это либо что-то вроде указателя на другой экземпляр).
Тут все зависит от контекста проекта. Лично мне кажется удобным засунуть все подписки в один класс, чтобы они не мешались. Но у меня пока не возникало необходимости динамически под/отписываться.
Спасибо за наводку, кажется я придумал способ избежать ненужного взаимодействия с игровым полем при нажатии кнопки GUI (есть такая у меня проблема, нажимаю кнопку, а у меня кроме нее еще и на игровое поле клик считывается и корабль летит не туда)…
0
Tutanhomon ,  
Опасно подписываться на статику…
Если ваш мяч подпишется на событие из аггрегатора, а игра перейдет в сцену в которой мяча уже нет — подписка останется, но вызов события повалит вашу игру НаллРеференсом.
Схема сама по себе вполне рабочая и удобная, но нужно не забывать отписываться в OnDisable (для симметрии предпочитаю подписываться в OnEnable)
0
ArXen42 ,  
Спасибо, обязательно добавлю в статью. Чем мне и нравится хабр, написал статью, сразу узнал, что я делаю неправильно и как надо делать правильно.
0
+2 –2
aGosh ,  
Уважаемый автор, чтобы вам же было легче в будущем. Не используйте ивенты в Юнити:
1. В некоторых случаях они могут вызвать неисправимый меморилик.
2. Ивенты могут выглядеть как манна небесная и решение всех проблем. Но это не так.
3. Заивентить все стремятся люди, которые не могут продумать архитектуру приложения. Поверьте мне если вы продумаете архитектуру с жесткими связями, то сможете читать свой код без комментариев и любой другой программист тоже. С ивентами связанность и читаемость падает до 0.
4. Особенно если приложение работает неправильно, то с ивентами даже предположить что не так бывает сложно.
5. «Мировой эфир» — худшее, что можно посоветовать при использовании ивентов. Делайте архитектуру приложения!
6. Делайте архитектуру приложения!!!
+2
GrigoryPerepechko ,   * (был изменён)
1 — неисправимый таки?
3 — а мужики то и не знали. Жесткие связи значит — идеальная архитектура?
4 — может вы просто не умеете их готовить?
6 — а что вы вкладываете в «архитектуру приложения»?
0
ArXen42 ,   * (был изменён)
Вполне возможно. Но не имея достаточного опыта в программировании и геймдизайне, продумать правильную архитектуру практически не возможно. Я в своем проекте держу большую схему и стараюсь сначала продумать ее, а потом уже писать код, но сразу нарисовать ее для всей игры я не смогу, т.к. недалеко ушел от сферического нуба.
0
Tutanhomon ,  
Опять же, как Вы сами писали, опасно вызывать события на которые никто не подписан, поэтому:

Не
public void CallOnTurnSwitchEvent() { OnTurnSwitch_event(); }

А
public void CallOnTurnSwitchEvent() 
{
    if( OnTurnSwitch_event != null)
    {
        OnTurnSwitch_event();
    }
}
0
ArXen42 ,   * (был изменён)
Конкретно в данном примере игра не предполагает изменения подписок, поэтому в этом нет необходимости. А так, конечно, нужно. Ну и подписки распихать в OnEnable/Disable. Кстати, тут этот метод не нужен, забыл убрать.
0
ArXen42 ,  
Спасибо, забыл исправить.