Praktyczne wykorzystanie systemu encji opartej na komponentach

59

Wczoraj przeczytałem prezentację GDC Canada na temat systemu encji Attribute / Behavior i myślę, że jest całkiem świetny. Nie jestem jednak pewien, jak z niego korzystać praktycznie, nie tylko teoretycznie. Po pierwsze, szybko wyjaśnię ci, jak działa ten system.


Każda jednostka gry (obiekt gry) składa się z atrybutów (= danych, do których można uzyskać dostęp poprzez zachowania, ale także przez „kod zewnętrzny”) i zachowań (= logika, które zawierają OnUpdate()i OnMessage()). Na przykład w klonie Breakout każda cegła składałaby się (przykład!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . Ostatni może wyglądać tak (to tylko niedziałający przykład napisany w 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);
        }
    }
}

Jeśli jesteś zainteresowany tym systemem, możesz przeczytać więcej tutaj (.ppt).


Moje pytanie dotyczy tego systemu, ale ogólnie każdego systemu jednostek opartego na komponentach. Nigdy nie widziałem, jak którekolwiek z nich naprawdę działa w prawdziwych grach komputerowych, ponieważ nie mogę znaleźć dobrych przykładów, a jeśli je znajdę, nie jest to udokumentowane, nie ma komentarzy, więc nie rozumiem tego.

Więc o co chcę zapytać? Jak zaprojektować zachowania (komponenty). Przeczytałem tutaj, na GameDev SE, że najczęstszym błędem jest tworzenie wielu komponentów i po prostu „uczynienie wszystkiego składnikiem”. Czytałem, że sugeruje się, aby nie renderować w komponencie, ale zrobić to poza nim (więc zamiast RenderableBehaviour , może to być RenderableAttribute , a jeśli jednostka ma RenderableAttribute ustawioną na true, to Renderer(klasa niezwiązana z komponenty, ale do samego silnika) czy należy go narysować na ekranie?).

A co z zachowaniami / komponentami? Powiedzmy, że mam poziom, a na poziomie jest Entity button, Entity doorsi Entity player. Kiedy gracz zderza się z przyciskiem (jest to przycisk podłogi, który jest przełączany przez nacisk), zostaje wciśnięty. Naciśnięcie przycisku powoduje otwarcie drzwi. Cóż, teraz jak to zrobić?

Wymyśliłem coś takiego: gracz ma CollisionBehaviour , który sprawdza, czy gracz coś koliduje. Jeśli zderzy się z przyciskiem, wysyła on CollisionMessagedo buttonbytu. Wiadomość będzie zawierać wszystkie niezbędne informacje: kto zderzył się z przyciskiem. Przycisk ma ToggleableBehaviour , który otrzyma CollisionMessage. Sprawdzi, z kim się zderzył, a jeśli waga tego bytu jest wystarczająco duża, aby przełączyć przycisk, przycisk zostanie przełączony. Teraz ustawia ToggledAttribute przycisku na true. W porządku, ale co teraz?

Czy przycisk powinien wysłać kolejną wiadomość do wszystkich innych obiektów z informacją, że został przełączony? Wydaje mi się, że gdybym zrobił wszystko w ten sposób, miałbym tysiące wiadomości i byłoby bardzo bałagan. Może i tak jest lepiej: drzwi stale sprawdzają, czy przycisk, który jest z nimi połączony, jest wciśnięty, czy nie, i odpowiednio zmienia swój OpenedAttribute . Ale to oznacza, że OnUpdate()metoda drzwi będzie ciągle coś robić (czy to naprawdę problem?).

I drugi problem: co, jeśli mam więcej rodzajów przycisków. Jeden jest naciskany przez nacisk, drugi jest przełączany przez strzelanie do niego, trzeci jest przełączany, jeśli wyleje się na niego wodę itp. Oznacza to, że będę musiał zachowywać się inaczej, coś w tym rodzaju:

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

Czy tak działają prawdziwe gry, czy po prostu jestem głupia? Może mógłbym mieć tylko jeden ToggleableBehaviour i będzie się zachowywał zgodnie z ButtonTypeAttribute . Więc jeśli to jest ButtonType.Pressure, robi to, jeśli to jest ButtonType.Shot, robi coś innego ...

Więc czego chcę? Chciałbym cię zapytać, czy robię to dobrze, czy po prostu jestem głupi i nie zrozumiałem sensu komponentów. Nie znalazłem żadnego dobrego przykładu tego, jak naprawdę komponenty działają w grach, znalazłem tylko kilka samouczków opisujących, jak zbudować system komponentów, ale nie jak go używać.

TomsonTom
źródło

Odpowiedzi:

46

Komponenty są świetne, ale znalezienie rozwiązania, które jest dla Ciebie dobre, może zająć trochę czasu. Nie martw się, dostaniesz się tam. :)

Organizowanie komponentów

Powiedziałbym, że jesteś na dobrej drodze. Spróbuję opisać rozwiązanie na odwrót, zaczynając od drzwi, a kończąc na przełącznikach. Moja implementacja często wykorzystuje zdarzenia; poniżej opisuję, jak można efektywniej wykorzystywać zdarzenia, aby nie stały się problemem.

Jeśli masz mechanizm łączenia bytów między nimi, kazałbym przełącznikowi bezpośrednio powiadomić drzwi, że zostały naciśnięte, wtedy drzwi mogą zdecydować, co zrobić.

Jeśli nie możesz połączyć encji, twoje rozwiązanie jest bardzo zbliżone do tego, co bym zrobił. Chciałbym, żeby drzwi nasłuchały jakiegoś ogólnego zdarzenia ( SwitchActivatedEventbyć może). Po aktywacji przełączników publikują to zdarzenie.

Jeśli masz więcej niż jeden typ przełącznika, będę musiał PressureToggle, WaterTogglei A ShotToggleteż zachowania, ale nie jestem pewien, że baza ToggleableBehaviourjest dobry, tak bym pozbyć się, że (o ile, oczywiście, masz dobry powód, dla którego go trzymasz).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Wydajna obsługa zdarzeń

Jeśli chodzi o martwienie się, że latających jest zbyt wiele wydarzeń, możesz zrobić jedną rzecz. Zamiast powiadamiania każdego komponentu o każdym zdarzeniu, które ma miejsce, sprawdź, czy jest to odpowiedni typ zdarzenia, oto inny mechanizm ...

Można mieć EventDispatcherze subscribesposobu, który wygląda mniej więcej tak (Pseudokod):

EventDispatcher.subscribe(event_type, function)

Następnie, gdy publikujesz zdarzenie, dyspozytor sprawdza jego typ i powiadamia tylko te funkcje, które zasubskrybowały ten konkretny typ zdarzenia. Możesz zaimplementować to jako mapę, która wiąże typy zdarzeń z listami funkcji.

W ten sposób system jest znacznie bardziej wydajny: na wywołanie jest znacznie mniej wywołań funkcji, a komponenty mogą być pewne, że otrzymały odpowiedni typ zdarzenia i nie muszą podwójnie sprawdzać.

Jakiś czas temu opublikowałem prostą implementację tego na StackOverflow. Jest napisany w Pythonie, ale może nadal może ci pomóc:
https://stackoverflow.com/a/7294148/627005

Ta implementacja jest dość ogólna: działa z dowolną funkcją, nie tylko z komponentów. Jeśli nie jest to potrzebne, zamiast tego functionmożesz mieć behaviorparametr w swojej subscribemetodzie - instancję zachowania, która wymaga powiadomienia.

Atrybuty i zachowania

Sam zacząłem używać atrybutów i zachowań , zamiast zwykłych starych komponentów. Jednak z twojego opisu tego, jak używałbyś systemu w grze Breakout, myślę, że przesadzasz.

Korzystam z atrybutów tylko wtedy, gdy dwa zachowania potrzebują dostępu do tych samych danych. Atrybut pomaga zachować zachowania osobno, a zależności między komponentami (zarówno atrybutami, jak i zachowaniami) nie zostają uwikłane, ponieważ przestrzegają bardzo prostych i jasnych zasad:

  • Atrybuty nie używają żadnych innych składników (ani innych atrybutów, ani zachowań), są samowystarczalne.

  • Zachowania nie wykorzystują ani nie znają innych zachowań. Wiedzą tylko o niektórych atrybutach (tych, których ściśle potrzebują).

Gdy niektóre dane są potrzebne tylko przez jedno i tylko jedno z zachowań, nie widzę powodu, aby przypisywać je do atrybutu, pozwalam temu zachowaniu je zatrzymać.


komentarz @ heishe

Czy ten problem nie wystąpiłby również w przypadku normalnych komponentów?

W każdym razie nie muszę sprawdzać typów zdarzeń, ponieważ każda funkcja zawsze otrzyma odpowiedni typ zdarzenia .

Zależności zachowań (tj. Atrybuty, których potrzebują) są rozwiązywane na etapie budowy, więc nie musisz szukać atrybutów za każdym razem, gdy jest aktualizowana.

I na koniec używam Pythona do mojego kodu logiki gry (silnik jest jednak w C ++), więc nie ma potrzeby rzucania. Python robi swoje kaczki i wszystko działa dobrze. Ale nawet gdybym nie używał języka z pisaniem kaczych, zrobiłbym to (uproszczony przykład):

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_;
};

W przeciwieństwie do zachowań, atrybuty nie mają żadnej updatefunkcji - nie muszą, ich celem jest przechowywanie danych, a nie wykonywanie skomplikowanej logiki gry.

Nadal możesz mieć atrybuty wykonujące prostą logikę. W tym przykładzie HealthAttributemoże zapewnić, że 0 <= value <= max_healthjest to zawsze prawda. Może również wysłać HealthCriticalEventdo innych składników tego samego obiektu, gdy spadnie poniżej, powiedzmy, 25 procent, ale nie może wykonać bardziej skomplikowanej logiki.


Przykład klasy atrybutu:

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_;
};
Paul Manta
źródło
Dziękuję Ci! To jest dokładnie taka odpowiedź, jakiej chciałem. Podoba mi się również twój pomysł EventDispatcher niż proste przekazywanie wiadomości do wszystkich podmiotów. Teraz, do ostatniej rzeczy, którą mi powiedziałeś: po prostu mówisz, że Wpływ Zdrowia i Obrażeń nie musi być atrybutem w tym przykładzie. Więc zamiast atrybutów byłyby to tylko prywatne zmienne zachowań? Oznacza to, że „DamageImpact” przejdzie przez zdarzenie? Na przykład EventArgs.DamageImpact? Brzmi nieźle ... Ale jeśli chciałbym, aby cegła zmieniła kolor w zależności od jej zdrowia, to Zdrowie musiałoby być atrybutem, prawda? Dziękuję Ci!
TomsonTom
2
@TomsonTom Tak, to wszystko. Posiadanie zdarzeń zawierających dowolne dane, które słuchacze muszą znać, jest bardzo dobrym rozwiązaniem.
Paul Manta
3
To świetna odpowiedź! (jak w pliku pdf) - jeśli masz szansę, mógłbyś trochę rozwinąć sposób obsługi renderowania w tym systemie? Ten model atrybutu / zachowania jest dla mnie zupełnie nowy, ale bardzo intrygujący.
Michael
1
@TomsonTom O renderowaniu zobacz odpowiedź, którą dałem Michaelowi. Jeśli chodzi o kolizje, osobiście wybrałem skrót. Użyłem biblioteki o nazwie Box2D, która jest dość łatwa w użyciu i obsługuje kolizje znacznie lepiej niż mogłem. Ale nie używam biblioteki bezpośrednio w kodzie logiki gry. Każdy Entityma coś EntityBody, co odciąga wszystkie brzydkie kawałki. Zachowania mogą następnie odczytać pozycję z EntityBody, zastosować do niej siły, użyć stawów i silników, które ma ciało itp. Posiadanie tak wysokiej wierności symulacji fizyki jak Box2D z pewnością stwarza nowe wyzwania, ale są całkiem zabawne, imo.
Paul Manta
1
@thelinuxlich Więc jesteś programistą Artemis! : D Widziałem schemat Component/ System, do którego kilka razy przywoływano na płytkach. Nasze wdrożenia rzeczywiście mają całkiem sporo podobieństw.
Paul Manta