C ++: Система событий для игрового движка

Поскольку написание собственного игрового движка на C ++ кажется очень популярным в наши дни (серьезно, просто посмотрите на количество людей, представляющих свои WIP на YouTube), я решил попробовать это сам.

Моя ментальная модель системы событий выглядит так:

  • Events в основном сигналы, которые говорят вам, что что-то произошло. Некоторые типы событий могут также содержать дополнительную информацию о некотором состоянии в виде переменных-членов. Однако события не действуют. Это просто распространяемая информация.
  • Все классы, которые хотят участвовать в системе событий, должны реализовать EventHandler интерфейс.
  • EventHandlers несут ответственность за отправку, получение / хранение и обработку событий.
    • Каждый экземпляр EventHandler содержит список ссылок на другие EventHandlers. Эти другие обработчики получают транслируемые события.
    • Когда обработчик получает событие, он сохраняет это событие в очереди, чтобы можно было запланировать обработку.
    • Каждая реализация EventHandler интерфейс по-разному реагирует на события. К разным типам событий может потребоваться разное обращение.
  • «Пользователь» движка может определять все типы Events а также EventHandlers (т.е. их реализации).

Вот мой нынешний подход, который «работает» (я уверен, что он ужасен, так как пользователь должен методом проб и ошибок dynamic_cast событие):

  1. «Двигательная» сторона системы событий:
/**
 * Engine code
 */

// Event.hpp/cpp
class IEvent
{
    /* Event interface */
protected:
    virtual ~IEvent() = default;
};

// EventHandler.hpp/cpp
class IEventHandler
{
public:
    // Send events to other handlers
    void dispatch_event(const IEvent* event)
    {
        for (auto& recipient : event_recipients)
        {
            recipient->event_queue.push(event);
        }
    }
    // Invoke processing for events in queue when the time has come (oversimplified)
    void process_event_queue()
    {
        while (!event_queue.empty())
        {
            event_callback(event_queue.front());
            event_queue.pop();
        }
    }
    // Push to queue manually
    void push_queue(const IEvent* event)
    {
        event_queue.push(event);
    }

protected:
    // Store events so their processing can be scheduled
    std::queue<const IEvent*> event_queue;
    // Who will receive event dispatches from this handler
    std::set<IEventHandler*> event_recipients;
    // Process each individual event
    virtual void event_callback(const IEvent* event) = 0;
};
  1. Как «пользователь» обычно может с ним взаимодействовать:
/**
 * "User" code
 */

// UserEvents.hpp/cpp
class UserEventA : public IEvent {};
class UserEventB : public IEvent {};
class UserEventC : public IEvent {};

// UserEventHandler.hpp/cpp
class UserEventHandler : public IEventHandler
{
protected:
    // AFAIK this is painfully slow
    void event_callback(const IEvent* event) override
    {
        if (auto cast_event = dynamic_cast<const UserEventA*>(event))
        {
            cout << "A event" << endl;
        }
        else if (auto cast_event = dynamic_cast<const UserEventB*>(event))
        {
            cout << "B event" << endl;
        }
        else
        {
            cout << "Unknown event" << endl;
        }
    }
};

int main()
{
    // Create instances of user defined events
    UserEventA a;
    UserEventB b;
    UserEventC c;

    // Instance of user defined handler
    UserEventHandler handler;

    // Push events into handlers event queue
    handler.push_queue(&a);
    handler.push_queue(&b);
    handler.push_queue(&c);

    // Process events
    handler.process_event_queue();
}

Некоторые альтернативы, которые я уже изучал, но ни к чему не привел:

  1. Шаблон посетителя (с использованием двойной отправки) кажется хорошей идеей, но учитывает только возможность расширения «посещаемых объектов». IIRC «посетители» обычно имеют жестко определенный интерфейс. Однако здесь как Events и EventHandlers могут быть изменены, и поэтому я не думаю, что шаблон посетителя можно применить.
  2. Замена IEvent* в event_queue принадлежащий EventHandler интерфейс с std::variant позволил бы мне использовать сравнительно быстрый std::get_if вместо дорогостоящих dynamic_casts. Каждая реализация будет знать, какие типы событий она может обрабатывать. Однако это сделало бы диспетчеризацию событий между различными реализациями, которые принимают разные типы событий, невозможной из-за того, что их варианты (и, следовательно, их очереди) структурированы по-разному.

2 ответа
2

Почему?

Для чего это на самом деле? С каким «событием» мы имеем дело и почему нам нужно откладывать обработку этого события, а не просто вызывать функцию напрямую?

Зачем нам нужно стирание типа события вместо того, чтобы иметь дело с конкретными типами событий, например IEventHandler<T> реализация void event_callback(T const& e)?

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

(Я не говорю, что дизайн обязательно недействителен, но нам понадобятся конкретные примеры того, для чего он на самом деле используется в игре).


Отдельная отправка и обработка

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

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

Класс, которому нужно только получать события, также имеет ненужный список получателей.

Так что отдельный IEventHandler а также IEventDispatcher наверное было бы хорошей идеей.


Интерфейс и контроль доступа

std::queue<const IEvent*> event_queue;
std::set<IEventHandler*> event_recipients;

Делая эти protected немного опасно. Было бы лучше, если бы базовый класс реализовал более полный интерфейс (например, dispatcher.add_recipient(&foo_object);), а затем сделать эти переменные private.


Слишком много очередей

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

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


  • Я постараюсь ответить на ваши вопросы один за другим: 1. Почему?: Как я уже сказал, эти события действуют как уведомления общего назначения, на которые реагируют компоненты движка или игры. Они могут варьироваться от чего-то столь общего, как ввод с клавиатуры или закрытие окна приложения, до чего-то особенного, например, оружия, используемого в игре. Я не знаю какой EventHandler хотел бы реагировать на какие и сколько типов событий. Причина, по которой я буферизирую события, а не обрабатываю их напрямую, заключается в том, что для поддержания стабильной производительности моего игрового движка может потребоваться планирование процессов.

    — TheBeautifulOrc


  • 2. Интерфейс и контроль доступа: Вы абсолютно правы. 3. Отдельная отправка / обработка и слишком много очередей: Предлагаемый вами подход довольно интересен, и я пересмотрю его в будущем. Однако одна «диспетчерская очередь» может усложнить всю идею планирования обработки событий и не решит проблему, с которой я сейчас сталкиваюсь. Кроме того, оптимизация уменьшения количества очередей (помните, что они содержат только указатели на мои события, здесь нет дорогостоящего копирования) кажется действительно низкоуровневой и, вероятно, не является самой важной проблемой с моим текущим кодом.

    — TheBeautifulOrc


  • Хороший ответ, но я удивлен, что никто не комментирует вопросы управления сроком службы. Кажется немного странным, что очередь событий не получает права собственности на объект события, например, через интеллектуальный указатель. Итак, если я хочу создать событие, я должен создать объект события… и держись за него неопределенное время пока обработчик событий не дойдет до его обработки, о чем мне как-то придется узнать (как?), и только потом удалить его? (Приведенный пример кода, где события должны пережить обработчик событий скрывать эту проблему абсурдно.)

    — инди

Это плохая идея для игрового движка — так поступать с общими событиями.

В class IEvent не особо полезно. Операция динамического приведения — довольно тяжелая операция, и использовать ее для чего-то столь же простого, как щелчок мышью или щелчок на клавиатуре, — нехорошо.

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

Попробуйте попробовать дизайн, ориентированный на данные, вместо объектно-ориентированного дизайна. Вот ссылка от cppcon, объясняющая это против ООП

https://www.youtube.com/watch?v=yy8jQgmhbAU&ab_channel=CppCon

Я также отвечаю на некоторые комментарии из другого ответа:

  1. Почему ?: Как я уже сказал, эти события действуют как уведомления общего назначения для движка или компонентов игры, на которые они реагируют. Они могут варьироваться от чего-то столь же общего, как ввод с клавиатуры или закрытие окна приложения, до чего-то особенного, например, оружия, используемого в игре. Я не знаю, какой EventHandler хотел бы реагировать на какие и сколько типов событий. Причина, по которой я буферизирую события, а не обрабатываю их напрямую, заключается в том, что для поддержания стабильной производительности моего игрового движка может потребоваться планирование процессов.

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

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

    Добавить комментарий

    Ваш адрес email не будет опубликован. Обязательные поля помечены *