Events
Событие — механизм обмена информацией между системами или пользовательскими сервисами
- Представлено в виде пользовательской структуры с данными и маркер-интерфейсом
IEvent - Модель «отправитель → множество получателей» с автоматическим управлением жизненным циклом
- Каждый получатель имеет независимый курсор чтения
- Событие автоматически удаляется, когда все получатели его прочитали или оно подавлено
Пример:
public struct WeatherChanged : IEvent {
public WeatherType WeatherType;
}
public struct OnDamage : IEvent {
public float Amount;
public EntityGID Target;
}
Регистрация типа события доступна как в фазе Created, так и в Initialized
W.Create(WorldConfig.Default());
//...
// Простая регистрация
W.Types()
.Event<WeatherChanged>()
.Event<OnDamage>();
// Конфигурация предоставляется через реализацию IEventConfig<T> на структуре события
// (см. пример ниже)
//...
W.Initialize();
Для предоставления конфигурации реализуйте интерфейс IEventConfig<T> на структуре события. И ручная регистрация, и RegisterAll() используют его автоматически:
public struct WeatherChanged : IEvent, IEventConfig<WeatherChanged> {
public WeatherType WeatherType;
public EventTypeConfig<WeatherChanged> Config() => new(
guid: new Guid("..."), // стабильный идентификатор для сериализации (по умолчанию — автоматически из имени типа)
version: 1 // версия схемы данных для миграции (по умолчанию — 0)
);
}
Отправка событий:
// Отправить событие с данными
// Вернёт true если событие добавлено в буфер, false если нет зарегистрированных слушателей
bool sent = W.SendEvent(new WeatherChanged { WeatherType = WeatherType.Sunny });
// Отправить событие с default-значением
bool sent = W.SendEvent<OnDamage>();
Если нет зарегистрированных слушателей, SendEvent вернёт false и событие не будет сохранено. Регистрируйте слушателей до отправки событий.
Получение событий:
// Создать слушателя — каждый слушатель имеет независимый курсор чтения
var weatherReceiver = W.RegisterEventReceiver<WeatherChanged>();
// Отправить события
W.SendEvent(new WeatherChanged { WeatherType = WeatherType.Sunny });
W.SendEvent(new WeatherChanged { WeatherType = WeatherType.Rainy });
// Чтение событий через foreach
// После итерации события помечаются как прочитанные для данного слушателя
foreach (var e in weatherReceiver) {
ref var data = ref e.Value; // ref-доступ к данным события
Console.WriteLine(data.WeatherType);
}
// Дополнительная информация о событии при итерации
foreach (var e in weatherReceiver) {
// true если данный слушатель последний, кто читает это событие
// (событие будет удалено после прочтения)
bool last = e.IsLastReading();
// Количество слушателей, ещё не прочитавших это событие (без текущего)
int remaining = e.UnreadCount();
// Подавить событие — немедленно удаляет его для всех оставшихся слушателей
e.Suppress();
}
Управление слушателями:
// Чтение всех событий через делегат; возвращает число вызовов делегата
int handled = weatherReceiver.ReadAll(static (Event<WeatherChanged> e) => {
Console.WriteLine(e.Value.WeatherType);
});
// Подавить все непрочитанные события для данного слушателя
// События удаляются и другие слушатели больше не смогут их прочитать
// Возвращает число событий, фактически подавленных этим вызовом
int suppressed = weatherReceiver.SuppressAll();
// Пометить все события как прочитанные без обработки
// События не удаляются — другие слушатели всё ещё могут их прочитать
// Возвращает число событий, фактически помеченных как прочитанные
int marked = weatherReceiver.MarkAsReadAll();
// Удалить слушателя
W.DeleteEventReceiver(ref weatherReceiver);
ReadAll, MarkAsReadAll и SuppressAll возвращают число событий, по которым слушатель реально совершил работу за этот вызов. События, уже подавленные другим слушателем (либо ранее полностью дочитанные всеми остальными слушателями), молча пропускаются — с точки зрения текущего слушателя их «не было», и в возвращаемое число они не входят. Конкретно:
ReadAll(action)— число вызововaction.MarkAsReadAll()— число событий, у которых счётчик непрочитанного фактически декрементировался.SuppressAll()— число событий, у которых счётчик непрочитанного был ненулевым и обнулён данным вызовом.
Peek — инспекция без потребления:
Peek() возвращает итератор, который перебирает все непрочитанные события данного слушателя, не двигая курсор и не декрементируя UnreadReceiversCount. После выхода из foreach состояние ресивера не меняется — повторный foreach (var e in receiver.Peek()) вернёт те же события.
Подходит для многопроходных алгоритмов, диагностики, dry-run обработки.
foreach (var e in weatherReceiver.Peek()) {
Console.WriteLine(e.Value.WeatherType);
// данные доступны через e.Value, но событие НЕ помечается как прочитанное
}
// Регулярный foreach после Peek увидит все те же события и обработает их штатно:
foreach (var e in weatherReceiver) { ... }
LastOnly — потребление только в роли последнего читателя:
LastOnly() возвращает итератор, который проходит все непрочитанные события от текущей позиции курсора до конца, и yield-ит только те, для которых этот слушатель является последним непрочитавшим (равноценно IsLastReading() == true). Yield-нутые события автоматически помечаются прочитанными (декремент + очистка mask-бита), как в обычном foreach. События, которые ещё не «отработаны» всеми (UnreadCount > 1), пропускаются без изменений — они останутся доступными для следующих заходов. Курсор слушателя сдвигается только пока подряд идут уже завершённые события; как только пройдёт мимо неготового — курсор останавливается там, но итератор продолжает сканировать дальше в поисках позиций, где мы уже последние.
Это естественное выражение паттерна «выполнить действие ровно один раз после того, как все остальные слушатели обработали событие» — без зависимости от порядка систем внутри кадра.
Только один слушатель на тип события должен вызывать LastOnly() — два таких будут вечно ждать друг друга, и события зависнут навсегда. Это ответственность пользователя; фреймворк это не валидирует.
Пример: уничтожение сущности после смерти. Когда сущность умирает, в DeadEvent отправляется её EntityGID, а несколько систем должны успеть её прочитать (спавн лута, начисление опыта, звук смерти) — до того как сущность будет физически уничтожена. Если уничтожить её сразу при смерти, реакторы не смогут прочитать её компоненты; если уничтожить через ручную проверку IsLastReading() — задача требует гарантии, что уборщик запускается последним, чего обычно нет.
С LastOnly() уборщик просто ждёт нужного кадра:
public struct DeadEvent : IEvent { public EntityGID Gid; }
// Реакторы регистрируются обычным способом:
EventReceiver<GameWT, DeadEvent> lootReactor = W.RegisterEventReceiver<DeadEvent>();
EventReceiver<GameWT, DeadEvent> xpReactor = W.RegisterEventReceiver<DeadEvent>();
// Уборщик — обычная регистрация, но он будет вызывать LastOnly():
EventReceiver<GameWT, DeadEvent> deadCleaner = W.RegisterEventReceiver<DeadEvent>();
// При смерти — отправляем событие, сущность пока живёт:
W.SendEvent(new DeadEvent { Gid = entity.Gid() });
// Реактивные системы (любой порядок, обычный foreach):
foreach (var e in lootReactor) {
if (e.Value.Gid.TryUnpack(out var entity)) SpawnLoot(entity.Read<Position>());
}
foreach (var e in xpReactor) {
if (e.Value.Gid.TryUnpack(out var entity)) GiveXp(entity.Read<XpReward>().Amount);
}
// Система-уборщик (в любом месте пайплайна):
foreach (var e in deadCleaner.LastOnly()) {
if (e.Value.Gid.TryUnpack(out var entity)) {
entity.Destroy(); // безопасно: все остальные ресиверы уже отработали
}
// никаких MarkAsRead — итератор сам помечает прочитанным
}
Кадр N: реакторы и уборщик в произвольном порядке. Каждое событие, для которого хотя бы один реактор ещё не успел отработать, имеет UnreadCount > 1 — LastOnly() пропускает его без изменений (mask остаётся, курсор не двигается за него). События, на которых все реакторы уже успели отработать, уборщик читает и уничтожает сущность здесь же. Кадр N+1: оставшиеся реакторы дочитывают свои события, и следующий заход уборщика подберёт всё, что стало готово.
Многопоточность:
Отправка событий (SendEvent) потокобезопасна при соблюдении следующих условий:
- Несколько потоков могут одновременно вызывать
SendEventдля одного и того же типа событий - Одновременное чтение и отправка одного типа событий из разных потоков запрещены — отправка потокобезопасна только при отсутствии одновременного чтения того же типа
- Чтение событий одного типа (
foreach,ReadAll) должно выполняться в одном потоке - Разные типы событий можно читать из разных потоков одновременно, так как каждый тип хранится независимо
- Один и тот же тип событий можно читать из разных потоков в разное время (не одновременно)
Операции со слушателями (foreach, ReadAll, MarkAsReadAll, SuppressAll, создание и удаление слушателей) не поддерживаются в многопоточном режиме и должны выполняться только в основном потоке.
Жизненный цикл события:
Событие удаляется автоматически в двух случаях:
- Все зарегистрированные слушатели прочитали событие
- Событие было подавлено (
SuppressилиSuppressAll)
Важно, чтобы все зарегистрированные слушатели читали свои события (или вызывали MarkAsReadAll/SuppressAll), иначе события будут накапливаться в памяти.
// Пример жизненного цикла с двумя слушателями
var receiverA = W.RegisterEventReceiver<WeatherChanged>();
var receiverB = W.RegisterEventReceiver<WeatherChanged>();
W.SendEvent(new WeatherChanged { WeatherType = WeatherType.Sunny });
// Событие имеет UnreadCount = 2
foreach (var e in receiverA) {
// receiverA прочитал, UnreadCount = 1
}
foreach (var e in receiverB) {
// receiverB прочитал, UnreadCount = 0 → событие автоматически удаляется
}