Практическое использование компонентной системы сущностей

59

Вчера я прочитал презентацию от GDC Canada о системе сущностей Атрибут / Поведение, и я думаю, что это довольно здорово. Тем не менее, я не уверен, как использовать его практически, а не только в теории. Прежде всего, я быстро объясню вам, как работает эта система.


Каждый игровой объект (игровой объект) состоит из атрибутов (= данных, к которым можно обращаться как по поведению, но также по «внешнему коду»), так и по поведению (= логика, которая содержит OnUpdate()и OnMessage()). Так, например, в клоне Breakout, каждый кирпич будет состоять из (пример!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . Последний может выглядеть так (это просто нерабочий пример, написанный на C #):

void OnMessage(Message m)
{
    if (m is CollisionMessage) // CollisionMessage is inherited from Message
    {
        Entity otherEntity = m.CollidedWith; // Entity CollisionMessage.CollidedWith
        if (otherEntity.Type = EntityType.Ball) // Collided with ball
        {
            int brickHealth = GetAttribute<int>(Attribute.Health); // owner's attribute
            brickHealth -= otherEntity.GetAttribute<int>(Attribute.DamageImpact);
            SetAttribute<int>(Attribute.Health, brickHealth); // owner's attribute

            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
    else if (m is AttributeChangedMessage) // Some attribute has been changed 'externally'
    {
        if (m.Attribute == Attribute.Health)
        {
            // If health is <= 0, "destroy" the brick
            if (brickHealth <= 0)
                SetAttribute<bool>(Attribute.Alive, false);
        }
    }
}

Если вы заинтересованы в этой системе, вы можете прочитать больше здесь (.ppt).


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

Итак, что я хочу спросить? Как разработать модели поведения (компоненты). Я читал здесь, на GameDev SE, что самая распространенная ошибка - создавать множество компонентов и просто «делать все компонентом». Я читал, что предлагается не выполнять рендеринг в компоненте, а делать это вне его (поэтому вместо RenderableBehaviour это может быть RenderableAttribute , и если для сущности RenderableAttribute установлен в значение true, то Renderer(класс не связан с комплектующие, но для самого двигателя) должны ли нарисовать его на экране?).

Но как насчет поведения / компонентов? Давайте скажем , что у меня есть уровень, а на уровне, есть Entity button, Entity doorsи Entity player. Когда игрок сталкивается с кнопкой (это кнопка пола, которая переключается нажатием), он нажимается. Когда кнопка нажимается, она открывает двери. Ну а теперь как это сделать?

Я придумал что-то вроде этого: у игрока есть CollisionBehaviour , который проверяет, сталкивается ли игрок с чем-то. Если он сталкивается с кнопкой, он посылает CollisionMessageк buttonлицу. Сообщение будет содержать всю необходимую информацию: кто столкнулся с кнопкой. Кнопка имеет ToggleableBehaviour , который будет получать CollisionMessage. Он проверит, с кем он столкнулся, и если вес этого объекта достаточно велик для переключения кнопки, кнопка переключается. Теперь он устанавливает для атрибута ToggledAttribute кнопки значение true. Хорошо, но что теперь?

Должна ли кнопка отправить другое сообщение всем другим объектам, чтобы сообщить им, что оно было переключено? Я думаю, что если бы я делал все так, у меня были бы тысячи сообщений, и это было бы довольно грязно. Так что, может быть, это и лучше: двери постоянно проверяют, нажата или нет кнопка, связанная с ними, и соответственно изменяют свой атрибут OpenedAttribute . Но тогда это означает, что метод дверей OnUpdate()будет постоянно делать что-то (действительно ли это проблема?).

И вторая проблема: что, если у меня есть больше видов кнопок. Один нажимается давлением, второй переключается выстрелом, третий переключается, если на него льется вода и т. Д. Это означает, что мне придется вести себя по-разному, что-то вроде этого:

Behaviour -> ToggleableBehaviour -> ToggleOnPressureBehaviour
                                 -> ToggleOnShotBehaviour
                                 -> ToggleOnWaterBehaviour

Так работают настоящие игры или я просто тупой? Может быть, у меня может быть только один ToggleableBehaviour, и он будет вести себя в соответствии с атрибутом ButtonTypeAttribute . Так что, если это ButtonType.Pressure, он делает это, если это ButtonType.Shot, он делает что-то еще ...

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

TomsonTom
источник

Ответы:

46

Компоненты отличные, но может потребоваться некоторое время, чтобы найти решение, которое вам нравится. Не волнуйся, ты доберешься. :)

Организационные компоненты

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

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

Если вы не можете соединить сущности, ваше решение очень близко к тому, что я бы сделал. Я бы попросил дверь послушать общее событие ( SwitchActivatedEventможет быть). Когда переключатели активируются, они публикуют это событие.

Если у вас есть более чем один тип коммутатора, я бы PressureToggle, WaterToggleи через ShotToggleповедение тоже, но я не уверен , что база ToggleableBehaviourничего хорошего, так что я бы покончить с этим (если, конечно, у вас есть хороший причина для его сохранения).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Эффективная обработка событий

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

Вы можете иметь EventDispatcherс subscribeметодом , который выглядит примерно так (псевдокод):

EventDispatcher.subscribe(event_type, function)

Затем, когда вы публикуете событие, диспетчер проверяет его тип и уведомляет только те функции, которые подписались на этот конкретный тип события. Вы можете реализовать это как карту, которая связывает типы событий со списками функций.

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

Я опубликовал простую реализацию этого некоторое время назад на StackOverflow. Он написан на Python, но, возможно, он все еще может помочь вам:
https://stackoverflow.com/a/7294148/627005

Эта реализация довольно общая: она работает с любыми функциями, а не только с компонентами. Если вам это не нужно, вместо этого в вашем методе functionможет быть behaviorпараметр subscribe- экземпляр поведения, о котором нужно уведомить.

Атрибуты и поведение

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

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

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

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

Когда некоторые данные нужны только одному и только одному поведению, я не вижу смысла помещать их в атрибут, я позволяю поведению удерживать их.


@ Heishe's комментарий

Разве эта проблема не возникнет и с обычными компонентами?

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

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

И, наконец, я использую Python для своего игрового логического кода (хотя движок на C ++), поэтому нет необходимости в приведении. Python делает свое дело, набирая утку, и все работает отлично. Но даже если бы я не использовал язык с типизацией утки, я бы сделал это (упрощенный пример):

class SomeBehavior
{
  public:
    SomeBehavior(std::map<std::string, Attribute*> attribs, EventDispatcher* events)
        // For the purposes of this example, I'll assume that the attributes I
        // receive are the right ones. 
        : health_(static_cast<HealthAttribute*>(attribs["health"])),
          armor_(static_cast<ArmorAttribute*>(attribs["armor"]))
    {
        // Boost's polymorphic_downcast would probably be more secure than
        // a static_cast here, but nonetheless...
        // Also, I'd probably use some smart pointers instead of plain
        // old C pointers for the attributes.

        // This is how I'd subscribe a function to a certain type of event.
        // The dispatcher returns a `Subscription` object; the subscription 
        // is alive for as long this object is alive.
        subscription_ = events->subscribe(event::type<DamageEvent>(),
            std::bind(&SomeBehavior::onDamageEvent, this, _1));
    }

    void onDamageEvent(std::shared_ptr<Event> e)
    {
        DamageEvent* damage = boost::polymorphic_downcast<DamageEvent*>(e.get());
        // Simplistic and incorrect formula: health = health - damage + armor
        health_->value(health_->value() - damage->amount() + armor_->protection());
    }

    void update(boost::chrono::duration timePassed)
    {
        // Behaviors also have an `update` function, just like
        // traditional components.
    }

  private:
    HealthAttribute* health_;
    ArmorAttribute* armor_;
    EventDispatcher::Subscription subscription_;
};

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

Вы все еще можете заставить свои атрибуты выполнять простую логику. В этом примере, HealthAttributeможет гарантировать, что 0 <= value <= max_healthвсегда верно. Он также может отправлять a HealthCriticalEventдругим компонентам того же объекта, когда он падает ниже, скажем, 25 процентов, но он не может выполнять логику более сложную, чем эта.


Пример класса атрибута:

class HealthAttribute : public EntityAttribute
{
  public:
    HealthAttribute(Entity* entity, double max, double critical)
        : max_(max), critical_(critical), current_(max)
    { }

    double value() const {
        return current_;
    }    

    void value(double val)
    {
        // Ensure that 0 <= current <= max 
        if (0 <= val && val <= max_)
            current_ = val;

        // Notify other components belonging to this entity that
        // health is too low.
        if (current_ <= critical_) {
            auto ev = std::shared_ptr<Event>(new HealthCriticalEvent())
            entity_->events().post(ev)
        }
    }

  private:
    double current_, max_, critical_;
};
Пол Манта
источник
Спасибо! Это именно тот ответ, который я хотел. Мне также нравится ваша идея EventDispatcher, чем простая передача сообщений всем сущностям. Теперь, последнее, что вы мне сказали: вы в основном говорите, что Health и DamageImpact не должны быть атрибутами в этом примере. Таким образом, вместо атрибутов они будут просто частными переменными поведения? Это означает, что «DamageImpact» будет проходить через событие? Например EventArgs.DamageImpact? Звучит хорошо ... Но если бы я хотел, чтобы кирпич менял цвет в зависимости от его здоровья, тогда здоровье должно быть атрибутом, верно? Спасибо!
TomsonTom
2
@TomsonTom Да, вот и все. Наличие событий, содержащих любые данные, которые должны знать слушатели, является очень хорошим решением.
Пол Манта
3
Это отличный ответ! (как ваш PDF) - Когда у вас есть шанс, не могли бы вы немного рассказать о том, как вы справляетесь с рендерингом с этой системой? Эта модель атрибута / поведения совершенно новая для меня, но очень интригующая.
Майкл
1
@TomsonTom По поводу рендеринга, посмотрите ответ, который я дал Майклу. Что касается столкновений, я лично взял ярлык. Я использовал библиотеку Box2D, которая довольно проста в использовании и обрабатывает столкновения намного лучше, чем я мог. Но я не использую библиотеку непосредственно в моем игровом логическом коде. У каждого Entityесть свой EntityBody, который абстрагирует все уродливые биты. Затем поведение может считывать позицию с позиции EntityBody, прикладывать к ней силы, использовать суставы и двигатели, которые есть у тела, и т. Д. Наличие такой физики с высокой точностью воспроизведения, как Box2D, безусловно, приносит новые вызовы, но они довольно забавные, imo.
Пол Манта
1
@thelinuxlich Так ты разработчик Артемиды! : D Я видел Component/ Systemсхему, на которую ссылались несколько раз на досках. Наши реализации действительно имеют довольно много общего.
Пол Манта