Usando sistema de entidade baseado em componentes praticamente

59

Ontem, li uma apresentação da GDC Canadá sobre o sistema de entidades de Atributos / Comportamentos e acho ótimo. No entanto, não tenho certeza de como usá-lo na prática, não apenas na teoria. Primeiramente, explicarei rapidamente como esse sistema funciona.


Cada entidade do jogo (objeto do jogo) é composta de atributos (= dados, que podem ser acessados ​​por comportamentos, mas também por 'código externo') e comportamentos (= lógica, que contêm OnUpdate()e OnMessage()). Assim, por exemplo, em um clone Breakout, cada tijolo seria composto por (exemplo!): PositionAttribute , ColorAttribute , HealthAttribute , RenderableBehaviour , HitBehaviour . O último poderia ter esta aparência (é apenas um exemplo não útil escrito em 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);
        }
    }
}

Se você está interessado neste sistema, pode ler mais aqui (.ppt).


Minha pergunta está relacionada a esse sistema, mas geralmente todos os sistemas de entidades baseados em componentes. Eu nunca vi como isso realmente funciona em jogos de computador reais, porque não consigo encontrar bons exemplos e, se o encontrar, não está documentado, não há comentários e, portanto, não o entendo.

Então, o que eu quero perguntar? Como projetar os comportamentos (componentes). Eu li aqui, no GameDev SE, que o erro mais comum é criar muitos componentes e simplesmente "transformar tudo em um componente". Eu li que é sugerido não fazer a renderização em um componente, mas fazê-lo fora dele (portanto, em vez de RenderableBehaviour , talvez seja RenderableAttribute , e se uma entidade tiver RenderableAttribute definido como true, então Renderer(classe não relacionada a componentes, mas para o próprio mecanismo) deve desenhá-lo na tela?).

Mas e os comportamentos / componentes? Vamos dizer que eu tenho um nível, e no nível, há uma Entity button, Entity doorse Entity player. Quando o jogador colide com o botão (é um botão do piso, que é alternado pela pressão), ele é pressionado. Quando o botão é pressionado, ele abre as portas. Bem, agora como fazer isso?

Eu vim com algo assim: o jogador tem o CollisionBehaviour , que verifica se o jogador colide com alguma coisa. Se ele colidir com um botão, ele envia um CollisionMessagepara a buttonentidade. A mensagem conterá todas as informações necessárias: quem colidiu com o botão. O botão possui ToggleableBehaviour , que receberá CollisionMessage. Ele verificará com quem colidiu e se o peso dessa entidade for grande o suficiente para alternar o botão, o botão será alternado. Agora, ele define o ToggledAttribute do botão como true. Tudo bem, mas e agora?

O botão deve enviar outra mensagem a todos os outros objetos para informar que foi alternado? Eu acho que se eu fizesse tudo assim, eu teria milhares de mensagens e ficaria bem confuso. Então, talvez isso seja melhor: as portas verificam constantemente se o botão que está vinculado a elas está pressionado ou não, e altera seu OpenedAttribute de acordo. Mas então isso significa que o OnUpdate()método das portas estará constantemente fazendo algo (é realmente um problema?).

E o segundo problema: e se eu tiver mais tipos de botões. Um é pressionado pela pressão, o segundo é alternado ao atirar nele, o terceiro é alternado se a água for derramada sobre ele etc. Isso significa que terei que ter comportamentos diferentes, algo como isto:

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

É assim que os jogos reais funcionam ou eu sou apenas estúpido? Talvez eu possa ter apenas um ToggleableBehaviour e ele se comportará de acordo com o ButtonTypeAttribute . Então, se é um ButtonType.Pressure, faz isso, se é um ButtonType.Shot, faz outra coisa ...

Então o que eu quero? Eu gostaria de perguntar se estou fazendo certo, ou sou apenas estúpido e não entendi o ponto dos componentes. Não encontrei nenhum bom exemplo de como os componentes realmente funcionam nos jogos, encontrei apenas alguns tutoriais que descrevem como criar o sistema de componentes, mas não como usá-lo.

TomsonTom
fonte

Respostas:

46

Os componentes são ótimos, mas pode levar algum tempo para encontrar uma solução que seja boa para você. Não se preocupe, você chegará lá. :)

Organizando componentes

Você está no caminho certo, eu diria. Vou tentar descrever a solução ao contrário, começando pela porta e terminando com os interruptores. Minha implementação faz uso pesado de eventos; abaixo, descrevo como você pode usar eventos com mais eficiência para que eles não se tornem um problema.

Se você tiver um mecanismo para conectar entidades entre eles, o comutador notificará diretamente a porta que foi pressionada, e a porta poderá decidir o que fazer.

Se você não pode conectar entidades, sua solução está bem próxima do que eu faria. Gostaria que a porta ouvisse um evento genérico ( SwitchActivatedEvent, talvez). Quando os comutadores são ativados, eles postam este evento.

Se você tiver mais de um tipo de opção, eu teria PressureToggle, WaterTogglee ShotToggletambém comportamentos, mas não tenho certeza de que a base ToggleableBehaviourseja boa, então eu acabaria com isso (a menos que, é claro, você tenha uma boa razão para mantê-lo).

Behaviour -> ToggleOnPressureBehaviour
          -> ToggleOnShotBehaviour
          -> ToggleOnWaterBehaviour

Manipulação eficiente de eventos

Quanto à preocupação de que haja muitos eventos por aí, há uma coisa que você pode fazer. Em vez de todos os componentes serem notificados de cada evento que ocorre, faça com que o componente verifique se é o tipo certo de evento, eis um mecanismo diferente ...

Você pode ter um EventDispatchercom um subscribemétodo parecido com este (pseudocódigo):

EventDispatcher.subscribe(event_type, function)

Então, quando você publica um evento, o expedidor verifica seu tipo e só notifica as funções que se inscreveram nesse tipo de evento específico. Você pode implementar isso como um mapa que associa tipos de eventos a listas de funções.

Dessa forma, o sistema é significativamente mais eficiente: há muito menos chamadas de função por evento, e os componentes podem ter certeza de que receberam o tipo certo de evento e não precisam checar novamente.

Publiquei uma implementação simples disso há algum tempo no StackOverflow. Está escrito em Python, mas talvez ainda possa ajudá-lo:
https://stackoverflow.com/a/7294148/627005

Essa implementação é bastante genérica: funciona com qualquer tipo de função, não apenas com os componentes. Se você não precisar disso, em vez de function, poderá ter um behaviorparâmetro no seu subscribemétodo - a instância de comportamento que precisa ser notificada.

Atributos e comportamentos

Eu mesmo passei a usar atributos e comportamentos , em vez de componentes simples e antigos. No entanto, a partir de sua descrição de como você usaria o sistema em um jogo Breakout, acho que você está exagerando.

Só uso atributos quando dois comportamentos precisam acessar os mesmos dados. O atributo ajuda a manter os comportamentos separados e as dependências entre os componentes (sejam atributos ou comportamentos) não se enredam, porque seguem regras muito simples e claras:

  • Os atributos não usam nenhum outro componente (nem outros atributos nem comportamentos), eles são auto-suficientes.

  • Os comportamentos não usam ou conhecem outros comportamentos. Eles conhecem apenas alguns dos atributos (aqueles que eles precisam estritamente).

Quando alguns dados são necessários apenas para um e apenas um dos comportamentos, não vejo razão para colocá-los em um atributo, deixo o comportamento retê-los.


Comentário de @ heishe

Esse problema também não ocorreria com componentes normais?

De qualquer forma, eu não tenho de verificar os tipos de eventos porque cada função é a certeza de receber o tipo certo de evento, sempre .

Além disso, as dependências dos comportamentos (ou seja, os atributos que eles precisam) são resolvidas na construção, para que você não precise procurar atributos a cada atualização.

E, finalmente, eu uso o Python para o meu código de lógica de jogo (o mecanismo está em C ++), então não há necessidade de transmissão. Python faz sua digitação de pato e tudo funciona bem. Mas mesmo se eu não usasse um idioma com digitação de pato, faria isso (exemplo simplificado):

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

Diferentemente dos comportamentos, os atributos não têm nenhuma updatefunção - eles não precisam, seu objetivo é armazenar dados, não executar lógica de jogo complexa.

Você ainda pode ter seus atributos executando alguma lógica simples. Neste exemplo, um HealthAttributepode garantir que isso 0 <= value <= max_healthseja sempre verdadeiro. Ele também pode enviar um HealthCriticalEventpara outros componentes da mesma entidade quando ele cai abaixo de, digamos, 25%, mas não pode executar uma lógica mais complexa que essa.


Exemplo de uma classe de atributo:

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
fonte
Obrigado! Isso é exatamente o que eu queria. Também gosto da sua idéia do EventDispatcher melhor do que a simples mensagem que passa para todas as entidades. Agora, até a última coisa que você me disse: você basicamente diz que Health e DamageImpact não precisam ser atributos neste exemplo. Então, em vez de atributos, eles seriam apenas variáveis ​​privadas dos comportamentos? Isso significa que o "DamageImpact" seria aprovado no evento? Por exemplo, EventArgs.DamageImpact? Parece bom ... Mas se eu quisesse que o tijolo mudasse de cor de acordo com a saúde, a Saúde teria que ser um atributo, certo? Obrigado!
TomsonTom
2
@TomsonTom Sim, é isso. Manter os eventos com os dados que os ouvintes precisam saber é uma solução muito boa.
Paul Manta
3
Esta é uma ótima resposta! (como está o seu pdf) - Quando você tem uma chance, você pode elaborar um pouco sobre como você lida com a renderização com este sistema? Esse modelo de atributo / comportamento é completamente novo para mim, mas muito intrigante.
Michael
11
@TomsonTom Sobre a renderização, veja a resposta que dei a Michael. Quanto a colisões, eu pessoalmente peguei um atalho. Eu usei uma biblioteca chamada Box2D, que é bastante fácil de usar e lida com colisões muito melhor do que eu poderia. Mas eu não uso a biblioteca diretamente no meu código de lógica do jogo. Todo mundo Entitytem um EntityBody, que abstrai todos os pedaços feios. Os comportamentos podem então ler a posição do EntityBody, aplicar forças a ele, usar as articulações e os motores que o corpo possui, etc. Ter uma simulação de física de alta fidelidade como o Box2D certamente traz novos desafios, mas são bastante divertidos.
Paul Manta
11
@thelinuxlich Então você é o desenvolvedor do Artemis! : D Já vi o esquema Component/ Systemreferenciado algumas vezes nos fóruns. Nossas implementações realmente têm muitas semelhanças.
Paul Manta