Форум программистов, компьютерный форум, киберфорум
Storm23
Войти
Регистрация
Восстановить пароль
Рейтинг: 5.00. Голосов: 5.

Паттерн StateBus вместо MessageBus в Unity3D

Запись от Storm23 размещена 03.11.2018 в 13:49
Обновил(-а) Storm23 05.04.2019 в 18:29
Метки unity

MessageBus

Один из паттернов организации взаимодействия в Unity - это MessageBus (шина сообщений).
Это разновидность паттерна Publisher-Subscriber (Издатель-Подписчик), с той разницей, что события объявляются не в Publisher, а в глобальном синглтоне MessageBus. Таким образом, разрывается зависимость подписчика и издателя. Подписчику не нужно знать какой именно класс генерирует события, он просто подписывается в MessageBus на нужное ему событие. А кто его будет вызывать - для подписчика не важно.

Такая организация событий лучше подходит для взаимодействия объектов, чем Publisher-Subscriber, и лучше чем стандартные средства Unity, типа SendMessage().

Здесь я не буду расписывать, чем MessageBus лучше обычной событийной модели. Об этом можно почитать по ссылкам ниже. Моя цель сделать MessageBus более удобным и универсальным.

Практическая реализация паттерна MessageBus имеет свои минусы.
Для примера посмотрим на вот этот вариант1, на вот этот вариант2. А также еще на эту статью, в которой объясняются ключевые моменты MessageBus, и приводится третий вариант MessageBus.

Что бы не утомлять кликаньем по ссылкам я приведу частично коды этих библиотек, что бы было понятно о чем идет речь:
Вариант 1
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
using System;
using System.Collections.Generic;
 
public delegate void Callback();
public delegate void Callback<T>(T arg1);
public delegate void Callback<T, U>(T arg1, U arg2);
public delegate void Callback<T, U, V>(T arg1, U arg2, V arg3);
 
public enum MessengerMode {
    DONT_REQUIRE_LISTENER,
    REQUIRE_LISTENER,
}
 
 
static internal class MessengerInternal {
    static public Dictionary<string, Delegate> eventTable = new Dictionary<string, Delegate>();
    static public readonly MessengerMode DEFAULT_MODE = MessengerMode.DONT_REQUIRE_LISTENER;
 
    static public void OnListenerAdding(string eventType, Delegate listenerBeingAdded) {
        if (!eventTable.ContainsKey(eventType)) {
            eventTable.Add(eventType, null);
        }
 
        Delegate d = eventTable[eventType];
        if (d != null && d.GetType() != listenerBeingAdded.GetType()) {
            throw new ListenerException(string.Format("Attempting to add listener with inconsistent signature for event type {0}. Current listeners have type {1} and listener being added has type {2}", eventType, d.GetType().Name, listenerBeingAdded.GetType().Name));
        }
    }
 
    static public void OnListenerRemoving(string eventType, Delegate listenerBeingRemoved) {
        if (eventTable.ContainsKey(eventType)) {
            Delegate d = eventTable[eventType];
 
            if (d == null) {
                throw new ListenerException(string.Format("Attempting to remove listener with for event type {0} but current listener is null.", eventType));
            } else if (d.GetType() != listenerBeingRemoved.GetType()) {
                throw new ListenerException(string.Format("Attempting to remove listener with inconsistent signature for event type {0}. Current listeners have type {1} and listener being removed has type {2}", eventType, d.GetType().Name, listenerBeingRemoved.GetType().Name));
            }
        } else {
            throw new ListenerException(string.Format("Attempting to remove listener for type {0} but Messenger doesn't know about this event type.", eventType));
        }
    }
 
    static public void OnListenerRemoved(string eventType) {
        if (eventTable[eventType] == null) {
            eventTable.Remove(eventType);
        }
    }
 
    static public void OnBroadcasting(string eventType, MessengerMode mode) {
        if (mode == MessengerMode.REQUIRE_LISTENER && !eventTable.ContainsKey(eventType)) {
            throw new MessengerInternal.BroadcastException(string.Format("Broadcasting message {0} but no listener found.", eventType));
        }
    }
 
    static public BroadcastException CreateBroadcastSignatureException(string eventType) {
        return new BroadcastException(string.Format("Broadcasting message {0} but listeners have a different signature than the broadcaster.", eventType));
    }
 
    public class BroadcastException : Exception {
        public BroadcastException(string msg)
            : base(msg) {
        }
    }
 
    public class ListenerException : Exception {
        public ListenerException(string msg)
            : base(msg) {
        }
    }
}
 
 
// No parameters
static public class Messenger {
    private static Dictionary<string, Delegate> eventTable = MessengerInternal.eventTable;
 
    static public void AddListener(string eventType, Callback handler) {
        MessengerInternal.OnListenerAdding(eventType, handler);
        eventTable[eventType] = (Callback)eventTable[eventType] + handler;
    }
 
    static public void RemoveListener(string eventType, Callback handler) {
        MessengerInternal.OnListenerRemoving(eventType, handler);   
        eventTable[eventType] = (Callback)eventTable[eventType] - handler;
        MessengerInternal.OnListenerRemoved(eventType);
    }
 
    static public void Broadcast(string eventType) {
        Broadcast(eventType, MessengerInternal.DEFAULT_MODE);
    }
 
    static public void Broadcast(string eventType, MessengerMode mode) {
        MessengerInternal.OnBroadcasting(eventType, mode);
        Delegate d;
        if (eventTable.TryGetValue(eventType, out d)) {
            Callback callback = d as Callback;
            if (callback != null) {
                callback();
            } else {
                throw MessengerInternal.CreateBroadcastSignatureException(eventType);
            }
        }
    }
}
 
// One parameter
static public class Messenger<T> {
    private static Dictionary<string, Delegate> eventTable = MessengerInternal.eventTable;
 
    static public void AddListener(string eventType, Callback<T> handler) {
        MessengerInternal.OnListenerAdding(eventType, handler);
        eventTable[eventType] = (Callback<T>)eventTable[eventType] + handler;
    }
 
    static public void RemoveListener(string eventType, Callback<T> handler) {
        MessengerInternal.OnListenerRemoving(eventType, handler);
        eventTable[eventType] = (Callback<T>)eventTable[eventType] - handler;
        MessengerInternal.OnListenerRemoved(eventType);
    }
 
    static public void Broadcast(string eventType, T arg1) {
        Broadcast(eventType, arg1, MessengerInternal.DEFAULT_MODE);
    }
 
    static public void Broadcast(string eventType, T arg1, MessengerMode mode) {
        MessengerInternal.OnBroadcasting(eventType, mode);
        Delegate d;
        if (eventTable.TryGetValue(eventType, out d)) {
            Callback<T> callback = d as Callback<T>;
            if (callback != null) {
                callback(arg1);
            } else {
                throw MessengerInternal.CreateBroadcastSignatureException(eventType);
            }
        }
    }
}
 
 
// Two parameters
static public class Messenger<T, U> {
    private static Dictionary<string, Delegate> eventTable = MessengerInternal.eventTable;
 
    static public void AddListener(string eventType, Callback<T, U> handler) {
        MessengerInternal.OnListenerAdding(eventType, handler);
        eventTable[eventType] = (Callback<T, U>)eventTable[eventType] + handler;
    }
 
    static public void RemoveListener(string eventType, Callback<T, U> handler) {
        MessengerInternal.OnListenerRemoving(eventType, handler);
        eventTable[eventType] = (Callback<T, U>)eventTable[eventType] - handler;
        MessengerInternal.OnListenerRemoved(eventType);
    }
 
    static public void Broadcast(string eventType, T arg1, U arg2) {
        Broadcast(eventType, arg1, arg2, MessengerInternal.DEFAULT_MODE);
    }
 
    static public void Broadcast(string eventType, T arg1, U arg2, MessengerMode mode) {
        MessengerInternal.OnBroadcasting(eventType, mode);
        Delegate d;
        if (eventTable.TryGetValue(eventType, out d)) {
            Callback<T, U> callback = d as Callback<T, U>;
            if (callback != null) {
                callback(arg1, arg2);
            } else {
                throw MessengerInternal.CreateBroadcastSignatureException(eventType);
            }
        }
    }
}
 
 
// Three parameters
static public class Messenger<T, U, V> {
    private static Dictionary<string, Delegate> eventTable = MessengerInternal.eventTable;
 
    static public void AddListener(string eventType, Callback<T, U, V> handler) {
        MessengerInternal.OnListenerAdding(eventType, handler);
        eventTable[eventType] = (Callback<T, U, V>)eventTable[eventType] + handler;
    }
 
    static public void RemoveListener(string eventType, Callback<T, U, V> handler) {
        MessengerInternal.OnListenerRemoving(eventType, handler);
        eventTable[eventType] = (Callback<T, U, V>)eventTable[eventType] - handler;
        MessengerInternal.OnListenerRemoved(eventType);
    }
 
    static public void Broadcast(string eventType, T arg1, U arg2, V arg3) {
        Broadcast(eventType, arg1, arg2, arg3, MessengerInternal.DEFAULT_MODE);
    }
 
    static public void Broadcast(string eventType, T arg1, U arg2, V arg3, MessengerMode mode) {
        MessengerInternal.OnBroadcasting(eventType, mode);
        Delegate d;
        if (eventTable.TryGetValue(eventType, out d)) {
            Callback<T, U, V> callback = d as Callback<T, U, V>;
            if (callback != null) {
                callback(arg1, arg2, arg3);
            } else {
                throw MessengerInternal.CreateBroadcastSignatureException(eventType);
            }
        }
    }
}

Вариант 2
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public enum MessageType
{
    NONE,
    LevelStart,
    LevelEnd,
    PlayerPosition
}
 
public struct Message
{
    public MessageType Type;
    public int IntValue;
    public float FloatValue;
    public Vector3 Vector3Value;
    public GameObject GameObjectValue;
}
 
public struct MessageSubscriber
{
    public MessageType[] MessageTypes;
    public MessageHandler Handler;
}
 
    ....
 
    Dictionary<MessageType, List<MessageSubscriber>> subscriberLists = new Dictionary<MessageType, List<MessageSubscriber>>();
 
    public void AddSubscriber( MessageSubscriber subscriber )
    {
        MessageType[] messageTypes = subscriber.MessageTypes;
        for (int i = 0; i < messageTypes.Length; i++)
            AddSubscriberToMessage (messageTypes[i], subscriber);
    }
 
    public void SendMessage (Message message)
    {
        if (!subscriberLists.ContainsKey (message.Type))
            return;
 
        List<MessageSubscriber> subscriberList = 
            subscriberLists [message.Type];
 
        for (int i = 0; i < subscriberList.Count; i++)
            SendMessageToSubscriber (message, subscriberList [i]);
    }
 
    ....
 
    void Start()
    {
        MessageSubscriber subscriber = new MessageSubscriber ();
        subscriber.MessageTypes = MessageTypes;
        subscriber.Handler = Handler;
 
        MessageBus.Instance.AddSubscriber (subscriber);
    }

Вариант 3
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class EventAggregator
{
        public static UnitDiedEvent UnitDied;
}
 
....
 
private void Die()
{
        EventAggregator.UnitDied.Publish(this);
}
 
....
 
public class ScoresManager : MonoBehaviour
{
    public int Scores;
 
    public void Awake()
    {
        EventAggregator.UnitDied.Subscribe(OnUnitDied);
    }
 
    private void OnUnitDied(Unit unit)
    {
        Scores += CalculateScores(unit);
    }   
}


В целом, можно выделить такие недостатки этих реализаций:

1) Первый вариант плох почти всем. По сути это просто более производительная замена стандартного SendMessage из коробки Unity. Ему присущи все недостатки, описанные ниже. А плюс к этому фатальный недостаток: события системы нигде не формализируются и не имеют фиксированной сигнатуры. То есть в проекте нет такого места, куда можно посмотреть и увидеть все события, которые присутствуют в системе и их параметры. Каждый издатель регистрирует свое, одному ему известное событие с произвольными параметрами. Но как же подписчик узнает, что такое событие есть? Получается нужно лезть в код всех издателей и смотреть какое же там событие они генерируют, и с какими параметрами. Постойте, но основная цель MessageBus - это разрыв зависимости между объектами системы. А здесь получается сильная зависимость на уровне кода. Что бы написать код подписчика, нужно увидеть код издателя!

2) Второй вариант уже лучше. В вводной статье автор очень хорошо описывает чем плоха стандартная система Unity. И приводит свою реализацию MessageBus. Но вот сама реализация - не блещет. Да, теперь можно посмотреть какие есть события в системе - в enum MessageType перечисляются все типы сообщений. Но вот там ничего не говориться о сигнатуре этих событий. Автор решает проблему разных сигнатур созданием универсального сообщения Message, в котором указывается тип сообщения, и передается несколько фиксированных параметров: одно типа float, одно типа GameObject, одно типа int и т.д. Автор утверждает, что такое сообщение покрывает почти все возможные варианты сообщений. Думаю, не нужно быть Мартином Фаулером, что бы понять что здесь что-то не так, и такой подход плохо пахнет. По сути, в этом подходе сигнатура сообщений вообще нигде не описывается, а класс Message выглядит довольно уродливо.
Кроме того, этот подход довольно массивен в плане количества кода. Посмотрите фрагмент, где нужно подписываться на сообщения. Там четыре строки кода, и это не считая отдельного метода, который нужно будет создать для обработки событий. Этот обработчик, к тому же, должен содержать внутри себя объемистый switch, потому что ему передается тип сообщения, и он должен делать разные действия, в зависимости от этого типа.

3) Третий вариант выглядит лучше первых двух. В этом варианте каждое событие описывается отдельным классом, в котором описываются все параметры сообщения. Взглянув на список классов становится понятно, какие события есть в системе и какие параметры они передают.
Этот вариант также обращает внимание на возможность зацикливания шины сообщений. Эта проблема там обходится отложенным вызовом события. При публикации события, оно сначала попадает в очередь сообщений, и только в следующем Update - отправляется подписчикам. Это похоже на реализацию очереди сообщений в WinAPI.
Однако и этот вариант имеет недостатки. Во-первых, он содержит слишком много кода. Создавать отдельный класс под каждое сообщение, а потом еще прописывать его в шине - не самое лаконичное решение.
Во-вторых, в этом подходе (впрочем, как и в предыдущих) нужно отписываться от событий, если подписчик уничтожается. Если объект не отпишется от события - ссылка на него будет вечно висеть в списке подписчиков, он будет по прежнему получать сообщения, и сборщик мусора не сможет его уничтожить. Необходимость отписки сильно напрягает. По сути, это увеличивает инфраструктурный код в два раза. Потому что в одном месте нужно подписаться на события, а в другом - отписаться. И при этом нужно постоянно следить за тем, что бы список подписанных события строго совпадал со списком отписки. Не говоря уже о том, что уничтожение объекта может произойти асинхронно, и для отписки от событий, нам нужно подписаться на событие собственного уничтожения.

Еще хочу обратить внимание на еще одну проблему. Эта проблема не связана с конкретными реализациями. Это скорее ограничение MessageBus в принципе.

Допустим, мы делаем камеру, которая должна следить за игроком и перемещаться синхронно с ним. По логике MessageBus это решается так: игрок является издателем и отправляет сообщения о том, что его позиция поменялась, а камера подписывается на это сообщение и корректирует свою позицию. Но постойте. Камеру вообще-то не интересует изменение позиции игрока. Камеру интересует просто текущая позиция игрока, даже если она не менялась. Например, камера временно "переехала" на отображение какого-то предмета, а затем хочет вернуться к игроку. Но как это сделать, ведь камера не знает текущей позиции игрока? Она узнает позицию игрока только тогда, когда игрок начнет двигаться и сработает событие. Упс... получается MessageBus не подходит для слежения камеры за игроком? Конечно, это можно решить тем, что игрок в каждом фрейме будет генерировать событие, независимо от того, поменялась его позиция или нет. Это решает проблему, но это выпадает из концепции шины сообщений, потому что это скорее не событие, это просто передача своего состояния в шину. К тому-же, генерация события в каждом фрейме будет загружать систему и снижать производительность.

Другой пример. Мы хотим разделить объекты получающие управления от игрока (клики мышкой, нажатия клавиш и т.д.) и объекты, которые обрабатывают эти события. Кажется, что MessageBus решает эту задачу. Но... нет. Здесь возникает та же проблема, что и в предыдущем примере. Если, например, нам нужно знать нажата ли клавиша в данный момент, то издателю нужно слать сообщение о том, что она нажата в каждом фрейме. Иначе подписчики об этом не узнают. По сути здесь тоже важно не изменение состояние, а само состояние.

Это приводит к мысли, что было бы хорошо, если бы MessageBus умела передавать не только события, но и умела передавать состояние.

Итак, сформулируем цели, которые нужно достичь:

1) Все события должны быть явно описаны в коде, и иметь строго типизированную сигнатуру. Причем, это должно быть в одном классе, что бы можно было охватить взглядом все доступные события. А еще, желательно, что бы для событий работал IntelliSense, и мы могли бы выбрать подходящее событие просто из выпадающего списка в редакторе VS.
2) Нужно избежать необходимости отписки от события.
3) Нужно избежать проблемы зацикливания MessageBus. То есть нужно реализовать отложенное выполнение событий.
4) По возможности, нужно сделать код подписки, публикации и обработки событий - как можно более коротким и простым.
5) Хотелось бы иметь возможность публикации не только событий, но и состояний.
6) Ну и все это должно иметь приемлемое быстродействие.

Список выглядит внушительно? Да. Но мы попытаемся найти решение.

StateBus

Представим себе шину сообщений как набор флажков. Назовем ее StateBus. Каждый флажок будет представлять собой некоторое событие. Событие может происходить только один раз за фрейм. И значение true флажка обозначает, что в этом фрейме событие произошло. Значение false - событие не произошло.

Тогда подписчик может просто проверять значение флажка из StateBus в своем Update, и если оно true - выполнять обработку события.

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

На каждом фрейме очередь сдвигается, и очередной флажок из очереди попадает в шину, из которой подписчики уже могут его прочитать.
Нажмите на изображение для увеличения
Название: StateBus.png
Просмотров: 672
Размер:	6.7 Кб
ID:	5071

По сравнению с MessageBox, здесь инвертирована логика - не событие вызывает обработчики подписчика, а подписчик проверяет произошло ли событие, и реагирует на него.

Флажки не обязательно должны быть типа bool. Событие может отправлять сообщение любого типа. Например, если взрывается граната, она может отправлять в шину свои координаты взрыва. Если подобрана монета, она может отправлять в шину количество добавляемого золота, и так далее.

А что же с состояниями? Очень просто. Поскольку состояние не привязано к фреймам, а присутствует в шине постоянно, то состояние сделаем просто статическим полем в шине, без очереди. Издатель может менять это поле в произвольные моменты времени. Например, в шине можно объявить поле с текущими координатами игрока. Тогда контроллер игрока будет обновлять это поле в соответствии со своими координатами - просто выставляя поле в StateBus.

На практике, StateBus может выглядеть так:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
public static class StateBus
{
    //позиция игрока (состояние)
    public static Vector3 PlayerPosition;
 
    //событие нажатия кнопки Jump
    public static StateQueue<bool> InputJump;
 
    //событие взрыва гранаты, передающее свои координаты
    public static StateQueue<Vector3> Explosion;
 
    //....
}
События имеют тип StateQueue<T>, а состояния обозначены просто своим типом (например Vector3 PlayerPosition).
StateQueue<T> - это очередь событий. Очередь позволяет делать две операции - добавлять событие в очередь и читать текущее значение. В каждом фрейме очередь автоматически сдвигается и текущее значение берется из головы очереди.

Если очередь пуста, значение выставляется в null для ссылочных типов, и в значение по умолчанию для значимых типов.

Класс StateQueue<T> реализован так:

StateQueue<T>
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
    /// <summary>
    /// Queue of states
    /// </summary>
    public class StateQueue<T>: IStateQueue
    {
        // Queue of events
        private Queue<QueueItem<T>> queue = new Queue<QueueItem<T>>();
 
        /// <summary>
        /// Current value of state (in current frame)
        /// </summary>
        public T Value { get; private set; }
 
        /// <summary>
        /// Put event to queue
        /// </summary>
        public void Enqueue(T value, float deltaTime = 0)
        {
            queue.Enqueue(new QueueItem<T>{Value = value, TimeToFire = Time.time + deltaTime});
        }
 
        /// <summary>
        /// Implicit conversion to T
        /// </summary>
        public static implicit operator T(StateQueue<T> val)
        {
            return val.Value;
        }
 
        /// <summary>
        /// Conversion to true/false
        /// </summary>
        public static bool operator true(StateQueue<T> val)
        {
            return !val.Value.Equals(default(T));
        }
 
        /// <summary>
        /// Conversion to true/false
        /// </summary>
        public static bool operator false(StateQueue<T> val)
        {
            return val.Value.Equals(default(T));
        }
 
        /// <summary>
        /// Put event to queue via operator +
        /// </summary>
        public static StateQueue<T> operator +(StateQueue<T> vq, T val)
        {
            vq.Enqueue(val);
            return vq;
        }
 
        /// <summary>
        /// Clear queue, set default value
        /// </summary>
        public void Reset()
        {
            queue.Clear();
            Value = default(T);
        }
 
        void IStateQueue.Dequeue()
        {
            var count = queue.Count;
            for (int i = 0; i < count; i++)
            {
                //get next event
                var item = queue.Dequeue();
 
                //time elapsed?
                if (item.TimeToFire <= Time.time)
                {
                    //set event to current value
                    Value = item.Value;
                    return;
                }
 
                //time is not elapsed => enqueue again
                queue.Enqueue(item);
            }
 
            //set default value
            Value = default(T);
        }
    }


В классе переопределены ряд операторов, позволяющих удобно работать с событием.
Для того, что бы отправить событие в очередь, можно использовать оператор +=:

C#
1
StateBus.InputJump += true; // send message: user pressed Jump key
Для того, что бы проверить, что событие произошло, можно использовать неявное преобразование в bool:
C#
1
2
if (StateBus.InputJump) // if user pressed Jump key...
    ....;
Для того, что бы получить значение из события, можно использовать неявное преобразование:

C#
1
2
3
4
5
if (StateBus.Explosion) // if explosion happened ...
{
     Vector3 epicentre = StateBus.Explosion; // get explosion coordinates
     ...
}
Для установки состояния (без очереди событий) - можно просто присвоить значение:

C#
1
StateBus.PlayerPosition = transform.position; // set player position
Пример скрипта InputController, который устанавливает два состояния и генерирует одно событие:
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InputController : MonoBehaviour
{
    void Update ()
    {
        //set state InputHorizontal
        StateBus.InputHorizontal = Input.GetAxis("Horizontal");
        //set state InputVertical
        StateBus.InputVertical = Input.GetAxis("Vertical");
 
        //fire event InputJumpDown if Jump key pressed
        if (Input.GetButtonDown("Jump"))
            StateBus.InputJumpDown += true;
    }
}
Пример скрипта, который ловит событие Explosion:
C#
1
2
3
4
5
6
7
8
9
10
public class BoxController : MonoBehaviour
{
    private void Update()
    {
        //if explision => apply force to rigidbody
        if (StateBus.Explosion)
            //apply force from centre of explosion
            rigidbody.AddExplosionForce(3000, StateBus.Explosion, 20);
    }
}
Теперь проанализируем StateBus с точки зрения поставленных целей.

1) Все события и состояния прописаны явно - в виде статических типизированных полей StateBus. Это значит, что можно видеть все события системы в одном месте. Взглянув на код StateBus сразу видно логику взаимодействия всех частей системы, а также публикуемые состояния. Все поля строго типизированы. Это значит что мы сразу понимаем какие данные передает сообщение. Кроме того, без проблем работает IntelliSense и автоматический рефакторинг при переименовании событий. Никаких строковых идентификаторов.

2) Поскольку подписчик события на самом деле не подписывается, а просто проверяет флажок в своем Update, то ни процесса подписки ни процесса отписки просто нет. То есть код подписчика кардинально упрощается. Фактически, реакция на событие пишется одной строкой if (...), в которой проверяется флажок StateBus.

3) StateBus реализует отложенные события. Если событие сгенерировано в текущем фрейме, то подписчикам оно будет передано только в следующем фрейме. Это означает, что StateBus не будет зависать, если случится зацикливание сообщений.

4) Генерация и обработка событий имеет очень краткую и лаконичную запись. В одну строку.

5) Имеется возможность не только генерировать события, но и публиковать состояние.

6) Быстродействие StateBus немного ниже, чем у MessageBus. Это связано с тем, что проверка флажков состояния происходит всегда, в каждом фрейме. В то время, как классические события срабатывают только при отправке сообщения. Однако снижение производительности не критично. Потому что: а) метод Update скорее всего и так есть в скрипте подписчика; б) код проверки состояния сводится к проверке bool поля, и происходит очень быстро.

Таким образом, удается достигнуть всех целей, которые были поставлены.

Дополнительные возможности StateBus

StateBus позволяет генерировать отложенные события, которые будут отданы подписчикам через заданное время.
Это делается так:
C#
1
StateBus.PlayerDie.Enqueue(true, 10);
Событие PlayerDie будет сгенерировано через 10 секунд.

Другая возможность StateBus - это публикация сервисов. Суть сводится к тому, что издатель публикует не состояние, а функцию, которую может вызвать подписчик. Например, главный герой имеет свой коллайдер и отслеживает все объекты, которые находятся рядом с ним. Но как другие объекты могут узнать, находятся они рядом с игроком или нет? Для этого, скрипт публикует в StateBus функцию, типа такой:
C#
1
public static Func<GameObject, bool> IsNearPlayer;
А подписчики вызывают эту функцию, передают свой GameObject и получают bool, говорящий о том, находятся они рядом с игроком или нет:
C#
1
2
if (StateBus.IsNearPlayer(gameObject))
   // we are near player ...
Пример использования StateBus - игра Сапер

В присоединенном файле находится полный код проекта, в котором скрипты взаимодействуют друг с другом исключительно через StateBus.

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

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

В игре реализованы шесть контроллеров:
  • CameraController - передвигает камеру, вслед за игроком
  • GuiController - занимается отображением HUD
  • InputController - принимает ввод с клавиатуры
  • MineController - управляет миной
  • PlayerController - управляет главным героем
  • SoundController - занимается проигрыванием музыки

Шина состояний содержит три состояния, и три события:
C#
1
2
3
4
5
6
7
8
9
public static class StateBus
{
    public static Transform PlayerTransform;
    public static float InputVertical;
    public static float InputHorizontal;
    public static StateQueue<bool> InputJumpDown;
    public static StateQueue<Vector3> Explosion;
    public static StateQueue<bool> MineDeactivated;
}
Ниже приводятся примеры некоторых контроллеров:
MineController
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class MineController : MonoBehaviour
{
    private bool CanExplode;
 
    void Start ()
    {
        CanExplode = Random.value > 0.9;
    }
    
    void Update ()
    {
        //check distance to player
        if ((StateBus.PlayerTransform.position - transform.position).sqrMagnitude < 2)
        {
            if (CanExplode)
                // fire Explosion event, pass coordinates of explosion
                StateBus.Explosion += transform.position; 
            else
                // fire MineDeactivated event
                StateBus.MineDeactivated += true;
 
            Destroy(gameObject);
        }
 
        // if some mine is exploded => apply force
        if (StateBus.Explosion)
            GetComponent<Rigidbody>().AddExplosionForce(2000, StateBus.Explosion, 15);
    }
}

Кликните здесь для просмотра всего текста
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SoundController : MonoBehaviour
{
    public AudioClip Explision;
    public AudioClip Pickup;
    public AudioSource Source;
 
    void Update ()
    {
        if (StateBus.Explosion)
            Source.PlayOneShot(Explision);
 
        if (StateBus.MineDeactivated)
            Source.PlayOneShot(Pickup);
    }
}

Кликните здесь для просмотра всего текста
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class GuiController : MonoBehaviour
{
    public Text DeactivatedText;
    public Text ExplodedText;
    private int deactivated = 0;
    private int exploded = 0;
 
    void Update ()
    {
        if (StateBus.MineDeactivated)
        {
            deactivated++;
            DeactivatedText.text = "Defused: " + deactivated;
        }
 
        if (StateBus.Explosion)
        {
            exploded++;
            ExplodedText.text = "Explosion: " + exploded;
        }
    }
}


Как видим, все контроллеры независимы друг от друга. Они связаны лишь с событиями и состояниями StateBus. О существовании других контроллеров они ничего не знают. Мы можем заменить, удалить или добавить контроллеры, без нарушения работы всех остальных объектов игры.

Нажмите на изображение для увеличения
Название: Скриншот 2018-11-03 12.43.28.png
Просмотров: 593
Размер:	980.8 Кб
ID:	5072

Полный код проекта:
Вложения
Тип файла: zip StateBusDemo.zip (5.28 Мб, 256 просмотров)
Размещено в Без категории
Показов 11697 Комментарии 7
Всего комментариев 7
Комментарии
  1. Старый комментарий
    Аватар для 1max1
    Мне нравиться такая задумка, я раньше пользовался обычным мессенджером через строковые ключи и часто сталкивался с проблемами, которые описаны выше, особенно подписка-отписка))
    У меня лишь один вопрос. Если произойдет в одном фрейме сразу несколько одних и тех же событий, к примеру:
    C#
    1
    2
    3
    
    StateBus.Explosion += new Vector3(33, 0, 0); 
    StateBus.Explosion += new Vector3(77, 0, 0); 
    StateBus.Explosion += new Vector3(55, 0, 0);
    Получается что обработка их растянется на 3 фрейма? Я правильно понял? Ведь
    C#
    1
    
    if (StateBus.Explosion) ...
    срабатывает только один раз за фрейм.
    Запись от 1max1 размещена 09.11.2018 в 22:49 1max1 вне форума
  2. Старый комментарий
    Аватар для Storm23
    Цитата:
    Получается что обработка их растянется на 3 фрейма? Я правильно понял?
    Да, растянется на три фрейма.

    Но это можно обойти. Если заранее известно, что может происходить много событий за фрейм, можно сделать другой вид событий, который будет реализовывать IEnumerator.
    Тогда слушатель будет обрабатывать событие следующим образом:
    C#
    1
    2
    
    foreach(var explision in StateBus.Explosion)
       ...//process explosion
    И все события обработаются за один фрейм.
    Такая схема не реализована по умолчанию потому, что foreach все таки более тяжелая конструкция, по сравнению с if.
    Запись от Storm23 размещена 10.11.2018 в 00:39 Storm23 вне форума
    Обновил(-а) Storm23 10.11.2018 в 01:07
  3. Старый комментарий
    Аватар для 1max1
    Спасибо, довольно интересный подход, думаю теперь буду пользоваться таким мессенджером)
    Запись от 1max1 размещена 10.11.2018 в 09:12 1max1 вне форума
  4. Старый комментарий
    Аватар для Storm23
    Пожалуйста.
    Я сам его активно использую и доволен как слон. Раньше меня всегда смущали эти бесконечные ссылки на PlayerController (потому что надо знать координаты игрока), смущало то что, на каждый префаб нужно вешать и звук и спецэффекты, и логику поведения и все все все. То в StateBus это крадинально упрощается. Поведение отдельно, спецэффекты отдельно, камера отдельно, пользовательский ввод отдельно. Если нужно что-то поменять - меняется только в одном месте, а не в десятке префабов.
    По крайней мере на простых проектах работает на ура. На более сложных пока не пробовал. Если будете использовать - буду рад услышать отзывы.
    Запись от Storm23 размещена 10.11.2018 в 11:23 Storm23 вне форума
  5. Старый комментарий
    можете немного пояснить строки с 21 по 98 файла StateBus.cs
    пока это выше моего понимания
    Запись от MuaddibFremen размещена 24.07.2019 в 09:59 MuaddibFremen вне форума
  6. Старый комментарий
    для меня вообще магия как сам StateBus запускается в Демо

    я понимаю что магия скрыта тут, но как ?
    C#
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    class Updater : MonoBehaviour
        {
            private void Awake(){ StateBus.Awake(); }
            private void Start() { StateBus.Start(); }
            private void Update() { StateBus.Update(); }
            private void LateUpdate() { StateBus.LateUpdate(); }
        }
     
        [RuntimeInitializeOnLoadMethod]
        static void Init()
        {
            var updater = new GameObject(){name = "StateBusUpdater" };
            updater.AddComponent<Updater>();
            GameObject.DontDestroyOnLoad(updater);
        }
    Запись от MuaddibFremen размещена 10.08.2019 в 11:18 MuaddibFremen вне форума
  7. Старый комментарий
    Аватар для Storm23
    Цитата:
    для меня вообще магия как сам StateBus запускается в Демо
    Магия в том, что статические методы помеченные атрибутом [RuntimeInitializeOnLoadMethod] запускаются автоматически при старте игры. Поэтому метод Init запускается сам по себе.
    Затем этот метод создает пустой GameObject и вешает на него компонент Updater. Этот компонент уже периодически вызывает свои методы Start, Update и т.д.
    Что бы созданный GameObject не уничтожался при переходе со сцены на сцену - вызывается DontDestroyOnLoad.
    Запись от Storm23 размещена 10.08.2019 в 21:59 Storm23 вне форума
 
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2024, CyberForum.ru