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 > 1LastOnly() пропускает его без изменений (mask остаётся, курсор не двигается за него). События, на которых все реакторы уже успели отработать, уборщик читает и уничтожает сущность здесь же. Кадр N+1: оставшиеся реакторы дочитывают свои события, и следующий заход уборщика подберёт всё, что стало готово.


Многопоточность:

Отправка событий (SendEvent) потокобезопасна при соблюдении следующих условий:

  • Несколько потоков могут одновременно вызывать SendEvent для одного и того же типа событий
  • Одновременное чтение и отправка одного типа событий из разных потоков запрещены — отправка потокобезопасна только при отсутствии одновременного чтения того же типа
  • Чтение событий одного типа (foreach, ReadAll) должно выполняться в одном потоке
  • Разные типы событий можно читать из разных потоков одновременно, так как каждый тип хранится независимо
  • Один и тот же тип событий можно читать из разных потоков в разное время (не одновременно)

Операции со слушателями (foreach, ReadAll, MarkAsReadAll, SuppressAll, создание и удаление слушателей) не поддерживаются в многопоточном режиме и должны выполняться только в основном потоке.


Жизненный цикл события:

Событие удаляется автоматически в двух случаях:

  1. Все зарегистрированные слушатели прочитали событие
  2. Событие было подавлено (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 → событие автоматически удаляется
}

This site uses Just the Docs, a documentation theme for Jekyll.