Como os objetos do jogo devem estar cientes um do outro?


18

Acho difícil encontrar uma maneira de organizar os objetos do jogo para que sejam polimórficos, mas ao mesmo tempo não polimórficos.

Aqui está um exemplo: supondo que queremos todos os nossos objetos update()e draw(). Para fazer isso, precisamos definir uma classe base GameObjectque tenha esses dois métodos puros virtuais e permita que o polimorfismo entre em ação:

class World {
private:
    std::vector<GameObject*> objects;
public:
    // ...
    update() {
        for (auto& o : objects) o->update();
        for (auto& o : objects) o->draw(window);
    }
};

O método update deve cuidar de qualquer estado que o objeto de classe específico precise atualizar. O fato é que cada objeto precisa conhecer o mundo ao seu redor. Por exemplo:

  • Uma mina precisa saber se alguém está colidindo com ela
  • Um soldado deve saber se o soldado de outra equipe está próximo
  • Um zumbi deve saber onde está o cérebro mais próximo, dentro de um raio.

Para interações passivas (como a primeira), eu estava pensando que a detecção de colisão poderia delegar o que fazer em casos específicos de colisões no próprio objeto com a on_collide(GameObject*).

A maioria das outras informações (como os outros dois exemplos) poderia ser consultada apenas pelo mundo do jogo passado para o updatemétodo. Agora o mundo não distingue objetos com base em seu tipo (ele armazena todos os objetos em um único contêiner polimórfico); portanto, o que de fato ele retornará com um ideal world.entities_in(center, radius)é um contêiner de GameObject*. Mas é claro que o soldado não quer atacar outros soldados de sua equipe e um zumbi não se importa com outros zumbis. Então, precisamos distinguir o comportamento. Uma solução pode ser a seguinte:

void TeamASoldier::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<TeamBSoldier*>(e))
            // shoot towards enemy
}

void Zombie::update(const World& world) {
    auto list = world.entities_in(position, eye_sight);
    for (const auto& e : list)
        if (auto enemy = dynamic_cast<Human*>(e))
            // go and eat brain
}

mas é claro que o número de dynamic_cast<>por quadro pode ser terrivelmente alto, e todos sabemos o quão lento dynamic_castpode ser. O mesmo problema também se aplica ao on_collide(GameObject*)delegado que discutimos anteriormente.

Então, qual é a maneira ideal de organizar o código para que os objetos possam conhecer outros objetos e poder ignorá-los ou executar ações com base em seu tipo?


1
Eu acho que você está procurando uma implementação personalizada de C ++ RTTI versátil. No entanto, sua pergunta não parece ser apenas sobre mecanismos criteriosos de RTTI. As coisas que você pede são exigidas por quase todos os middlewares que o jogo usará (sistema de animação, física para citar alguns). Dependendo da lista de consultas suportadas, você pode enganar o RTTI usando IDs e índices em matrizes ou acabará criando um protocolo completo para oferecer suporte a alternativas mais baratas para dynamic_cast e type_info.
teodron

Eu desaconselharia usar o sistema de tipos para a lógica do jogo. Por exemplo, em vez de depender do resultado de dynamic_cast<Human*>, implemente algo como a bool GameObject::IsHuman(), que retorna falsepor padrão, mas é substituído para retornar truena Humanclasse.
congusbongus

um extra: você quase nunca envia uma tonelada de objetos para outras entidades que possam estar interessadas neles. Essa é uma otimização óbvia que você terá que considerar de verdade.
Teodron # 25/13

@congusbongus O uso de uma tabela v e IsAsubstituições personalizadas provou ser apenas marginalmente melhor do que a transmissão dinâmica na prática para mim. A melhor coisa a fazer é que o usuário tenha, sempre que possível, listas de dados classificadas em vez de iterar cegamente por todo o conjunto de entidades.
Teodron # 25/13

4
@ Jeffffrey: idealmente, você não escreve código de tipo específico. Você escreve código específico da interface ("interface" no sentido geral). Sua lógica para um TeamASoldiere TeamBSoldieré realmente idêntica - disparada contra qualquer pessoa do outro time. Tudo o que precisa de outras entidades é um GetTeam()método mais específico e, pelo exemplo de congusbongus, que pode ser abstraído ainda mais em um IsEnemyOf(this)tipo de interface. O código não precisa se preocupar com classificações taxonômicas de soldados, zumbis, jogadores, etc. Concentre-se na interação, não nos tipos.
quer

Respostas:


11

Em vez de implementar a tomada de decisão de cada entidade em si mesma, você pode alternativamente optar pelo padrão do controlador. Você teria classes de controlador central que estão cientes de todos os objetos (que são importantes para eles) e controlam seu comportamento.

Um MovementController lidaria com o movimento de todos os objetos que podem se mover (faça a localização da rota, atualize as posições com base nos vetores de movimento atuais).

Um MineBehaviorController verifica todas as minas e todos os soldados e ordena que uma mina exploda quando um soldado se aproxima demais.

Um ZombieBehaviorController verificaria todos os zumbis e soldados nas proximidades, escolheria o melhor alvo para cada zumbi e ordenaria que ele se movesse para lá e o atacasse (o movimento em si é tratado pelo MovementController).

Um SoldierBehaviorController analisaria toda a situação e, em seguida, apresentaria instruções táticas para todos os soldados (você se muda para lá, dispara, cura o cara ...). A execução real desses comandos de nível superior também seria realizada por controladores de nível inferior. Quando você se esforça, pode tornar a IA capaz de tomar decisões cooperativas bastante inteligentes.


1
Provavelmente, isso também é conhecido como o "sistema" que gerencia a lógica de certos tipos de componentes em uma arquitetura de Entidade-Componente.
teodron

Isso soa como uma solução no estilo C. Os componentes são agrupados em se std::mapentidades são apenas IDs e, em seguida, precisamos criar algum tipo de sistema de tipos (talvez com um componente de tag, porque o renderizador precisará saber o que desenhar); e se não quisermos fazer isso, precisaremos de um componente de desenho: mas ele precisa que o componente de posição saiba onde ser desenhado, então criamos dependências entre os componentes que resolvemos com um sistema de mensagens super complexo. É isso que você está sugerindo?
Shoe

1
@ Jeffffrey "Isso soa como uma solução no estilo C" - mesmo quando isso seria verdade, por que seria necessariamente uma coisa ruim? As outras preocupações podem ser válidas, mas existem soluções para elas. Infelizmente, um comentário é muito curto para abordar adequadamente cada um deles.
Philipp

1
@ Jeffffrey Usando a abordagem em que os componentes em si não possuem lógica e os "sistemas" são responsáveis ​​por lidar com toda a lógica, não cria dependências entre os componentes nem exige um sistema de mensagens super complexo (pelo menos não tão complexo) . Veja, por exemplo: gamadu.com/artemis/tutorial.html

1

Primeiro, tente implementar recursos para que os objetos permaneçam independentes um do outro, sempre que possível. Especialmente, você deseja fazer isso para multiencadeamento. No seu primeiro exemplo de código, o conjunto de todos os objetos pode ser dividido em conjuntos correspondentes ao número de núcleos da CPU e atualizado com muita eficiência.

Mas, como você disse, a interação com outros objetos é necessária para alguns recursos. Isso significa que o estado de todos os objetos deve ser sincronizado em alguns pontos. Em outras palavras, seu aplicativo deve aguardar que todas as tarefas paralelas sejam concluídas primeiro e depois aplicar cálculos que envolvam interação. É bom reduzir o número desses pontos de sincronização, pois eles sempre implicam que alguns encadeamentos devem esperar que outros terminem.

Portanto, sugiro armazenar em buffer essas informações sobre os objetos necessários de dentro de outros objetos. Dado esse buffer global, você pode atualizar todos os seus objetos independentemente um do outro, mas apenas dependentes deles mesmos e do buffer global, que é mais rápido e fácil de manter. Em um intervalo de tempo fixo, digamos, após cada quadro, atualize o buffer com o estado atual dos objetos.

Então, o que você faz uma vez por quadro é: 1. armazena globalmente o estado dos objetos atuais; 2. atualiza todos os objetos baseados em si mesmos e no buffer;


1

Use um sistema baseado em componentes, no qual você possui um GameObject barebones que contém 1 ou mais componentes que definem seu comportamento.

Por exemplo, digamos que algum objeto deva se mover para a esquerda e direita o tempo todo (uma plataforma), você pode criar esse componente e anexá-lo a um GameObject.

Agora, digamos que um objeto de jogo deva girar lentamente o tempo todo, você pode criar um componente separado que faça exatamente isso e anexá-lo ao GameObject.

E se você quiser ter uma plataforma móvel que também gire, em uma hierarquia de classe tradicional que se torna difícil de fazer sem duplicar o código.

A beleza desse sistema é que, em vez de ter uma classe Rotatable ou MovingPlatform, você anexa esses dois componentes ao GameObject e agora possui um MovingPlatform que roda automaticamente.

Todos os componentes têm uma propriedade, 'requiredUpdate', que, enquanto verdadeiro, o GameObject chamará o método 'update' no referido componente. Por exemplo, suponha que você tenha um componente Arrastável, esse componente ao passar o mouse para baixo (se estiver sobre o GameObject) pode definir 'requireUpdate' como true e, em seguida, na configuração do mouse para false. Permitindo que ele siga o mouse apenas quando o mouse estiver pressionado.

Um dos desenvolvedores do Tony Hawk Pro Skater tem o artigo escrito e vale a pena ler: http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/


1

Favorecer a composição sobre a herança.

Meu conselho mais forte, além disso, seria: não se deixe levar pela mentalidade de "quero que isso seja extremamente flexível". A flexibilidade é ótima, mas lembre-se de que, em algum nível, em qualquer sistema finito como um jogo, existem partes atômicas que são usadas para construir o todo. De uma maneira ou de outra, seu processamento depende desses tipos atômicos predefinidos. Em outras palavras, atender a "qualquer" tipo de dados (se possível) não ajudaria a longo prazo, se você não tiver código para processá-los. Fundamentalmente, todo código deve analisar / processar dados com base em especificações conhecidas ... o que significa um conjunto predefinido de tipos. Qual o tamanho desse conjunto? Você decide.

Este artigo oferece uma visão do princípio da composição sobre a herança no desenvolvimento de jogos por meio de uma arquitetura de componente de entidade robusta e com bom desempenho.

Ao criar entidades a partir de subconjuntos (diferentes) de algum superconjunto de componentes predefinidos, você oferece aos seus IAs maneiras concretas e fragmentadas de compreender o mundo e os atores ao seu redor, lendo os estados desses componentes.


1

Pessoalmente, recomendo manter a função draw fora da própria classe Object. Eu até recomendo manter a localização / coordenadas dos objetos fora do próprio objeto.

Esse método draw () vai lidar com a API de renderização de baixo nível do OpenGL, OpenGL ES, Direct3D, sua camada de quebra automática nessas APIs ou uma API de mecanismos. Pode ser que você tenha que trocar entre eles (se você quisesse dar suporte ao OpenGL + OpenGL ES + Direct3D, por exemplo.

Esse GameObject deve conter apenas as informações básicas sobre sua aparência visual, como uma malha ou talvez um pacote maior, incluindo entradas de sombreador, estado de animação e assim por diante.

Você também vai querer um pipeline gráfico flexível. O que acontece se você deseja solicitar objetos com base na distância da câmera. Ou seu tipo de material. O que acontece se você deseja desenhar um objeto 'selecionado' com uma cor diferente. E se, em vez de realmente renderizar tanto quanto você chamar uma função de desenho em um objeto, ele a colocar em uma lista de comandos de ações a serem executadas pela renderização (pode ser necessário para a segmentação). Você pode fazer esse tipo de coisa com o outro sistema, mas é uma PITA.

O que eu recomendo é que, em vez de desenhar diretamente, você vincule todos os objetos que deseja a outra estrutura de dados. Essa ligação realmente precisa apenas ter uma referência ao local dos objetos e às informações de renderização.

Seus níveis / partes / áreas / mapas / hubs / mundo inteiro / qualquer que seja um índice espacial, ele contém os objetos e os retorna com base em consultas de coordenadas e pode ser uma lista simples ou algo como um Octree. Também poderia ser um invólucro para algo implementado por um mecanismo de física de terceiros como um cenário de física. Ele permite que você faça coisas como "Consultar todos os objetos que estão à vista da câmera com alguma área extra ao seu redor" ou para jogos mais simples, nos quais você pode simplesmente renderizar tudo e pegar a lista inteira.

Os índices espaciais não precisam conter as informações de posicionamento reais. Eles trabalham armazenando objetos em estruturas de árvores em relação à localização de outros objetos. Eles podem ser considerados como um tipo de cache com perdas, permitindo uma pesquisa rápida de um objeto com base em sua posição. Não há necessidade real de duplicar suas coordenadas X, Y, Z reais. Dito isto, você poderia se quisesse manter

Na verdade, seus objetos de jogo nem precisam conter suas próprias informações de localização. Por exemplo, um objeto que não foi colocado em um nível não deve ter coordenadas x, y, z, isso não faz sentido. Você pode conter isso no índice especial. Se você precisar procurar as coordenadas do objeto com base em sua referência real, desejará ter uma ligação entre o objeto e o gráfico de cena (os gráficos de cena são para retornar objetos com base em coordenadas, mas são lentos em retornar coordenadas com base em objetos) .

Quando você adiciona um objeto a um nível. Ele fará o seguinte:

1) Crie uma estrutura de localização:

 class Location { 
     float x, y, z; // Or a special Coordinates class, or a vec3 or whatever.
     SpacialIndex& spacialIndex; // Note this could be the area/level/map/whatever here
 };

Isso também pode ser uma referência a um objeto em mecanismos de física de terceiros. Ou pode ser uma coordenada de deslocamento com uma referência a outro local (para uma câmera de rastreamento ou um objeto ou exemplo anexado). Com o polimorfismo, pode ser dependendo se é um objeto estático ou dinâmico. Mantendo aqui uma referência ao índice espacial, quando as coordenadas são atualizadas, o índice espacial também pode ser.

Se você estiver preocupado com a alocação dinâmica de memória, use um conjunto de memórias.

2) Uma ligação / ligação entre seu objeto, sua localização e o gráfico da cena.

typedef std::pair<Object, Location> SpacialBinding.

3) A ligação é adicionada ao índice espacial dentro do nível no ponto apropriado.

Quando você está se preparando para renderizar.

1) Pegue a câmera (será apenas outro objeto, exceto que sua localização rastreará o personagem dos jogadores e seu renderizador terá uma referência especial a ela, de fato, é tudo o que realmente precisa).

2) Obtenha o SpacialBinding da câmera.

3) Obtenha o índice espacial da ligação.

4) Consulte os objetos que são (possivelmente) visíveis para a câmera.

5A) Você precisa ter as informações visuais processadas. Texturas carregadas na GPU e assim por diante. É melhor fazer isso com antecedência (como na carga nivelada), mas talvez possa ser feito em tempo de execução (para um mundo aberto, você pode carregar coisas quando estiver próximo de um pedaço, mas ainda assim deve ser feito com antecedência).

5B) Opcionalmente, crie uma árvore de renderização em cache, se você quiser classificar em profundidade / material ou acompanhar objetos próximos que possam estar visíveis posteriormente. Caso contrário, você pode simplesmente consultar o índice espacial sempre que ele depender de seus requisitos de jogo / desempenho.

Seu renderizador provavelmente precisará de um objeto RenderBinding que se vincule entre o Object, as coordenadas

class RenderBinding {
    Object& object;
    RenderInformation& renderInfo;
    Location& location // This could just be a coordinates class.
}

Então, quando você renderizar, basta executar a lista.

Eu usei as referências acima, mas elas podem ser ponteiros inteligentes, ponteiros brutos, identificadores de objetos e assim por diante.

EDITAR:

class Game {
    weak_ptr<Camera> camera;
    Level level1;

    void init() {
        Camera camera(75.0_deg, 1.025_ratio, 1000_meters);
        auto template_player = loadObject("Player.json")
        auto player = level1.addObject(move(player), Position(1.0, 2.0, 3.0));
        level1.addObject(move(camera), getRelativePosition(player));

        auto template_bad_guy = loadObject("BadGuy.json")
        level1.addObject(template_bad_guy, {10, 10, 20});
        level1.addObject(template_bad_guy, {10, 30, 20});
        level1.addObject(move(template_bad_guy), {50, 30, 20});
    }

    void render() {
        camera->getFrustrum();
        auto level = camera->getLocation()->getLevel();
        auto object = level.getVisible(camera);
        for(object : objects) {
            render(objects);
        }
    }

    void render(Object& object) {
        auto ri = object.getRenderInfo();
        renderVBO(ri.getVBO());
    }

    Object loadObject(string file) {
        Object object;
        // Load file from disk and set the properties
        // Upload mesh data, textures to GPU. Load shaders whatever.
        object.setHitPoints(// values from file);
        object.setRenderInfo(// data from 3D api);
    }
}

class Level {
    Octree octree;
    vector<ObjectPtr> objects;
    // NOTE: If your level is mesh based there might also be a BSP here. Or a hightmap for an openworld
    // There could also be a physics scene here.
    ObjectPtr addObject(Object&& object, Position& pos) {
        Location location(pos, level, object);
        objects.emplace_back(object);
        object->setLocation(location)
        return octree.addObject(location);
    }
    vector<Object> getVisible(Camera& camera) {
        auto f = camera.getFtrustrum();
        return octree.getObjectsInFrustrum(f);
    }
    void updatePosition(LocationPtr l) {
        octree->updatePosition(l);
    }
}

class Octree {
    OctreeNode root_node;
    ObjectPtr add(Location&& object) {
        return root_node.add(location);
    }
    vector<ObjectPtr> getObjectsInRadius(const vec3& position, const float& radius) { // pass to root_node };
    vector<ObjectPtr> getObjectsinFrustrum(const FrustrumShape frustrum;) {//...}
    void updatePosition(LocationPtr* l) {
        // Walk up from l.octree_node until you reach the new place
        // Check if objects are colliding
        // l.object.CollidedWith(other)
    }
}

class Object {
    Location location;
    RenderInfo render_info;
    Properties object_props;
    Position getPosition() { return getLocation().position; }
    Location getLocation() { return location; }
    void collidedWith(ObjectPtr other) {
        // if other.isPickup() && object.needs(other.pickupType()) pick it up, play sound whatever
    }
}

class Location {
    Position position;
    LevelPtr level;
    ObjectPtr object;
    OctreeNote octree_node;
    setPosition(Position position) {
        position = position;
        level.updatePosition(this);
    }
}

class Position {
    vec3 coordinates;
    vec3 rotation;
}

class RenderInfo {
    AnimationState anim;
}
class RenderInfo_OpenGL : public RenderInfo {
    GLuint vbo_object;
    GLuint texture_object;
    GLuint shader_object;
}

class Camera: public Object {
    Degrees fov;
    Ratio aspect;
    Meters draw_distance;
    Frustrum getFrustrum() {
        // Use above to make a skewed frustum box
    }
}

Quanto a tornar as coisas "conscientes" uma da outra. Isso é detecção de colisão. Provavelmente seria implementado no Octree. Você precisaria fornecer algum retorno de chamada em seu objeto principal. Esse material é melhor manipulado por um mecanismo de física adequado, como o Bullet. Nesse caso, substitua Octree por PhysicsScene e Position por um link para algo como CollisionMesh.getPosition ().


Uau, isso parece muito bom. Acho que compreendi a idéia básica, mas sem mais exemplos não consigo entender bem a visão externa. Você tem mais referências ou exemplos ao vivo sobre isso? (Enquanto isso, continuarei lendo esta resposta por um tempo).
Shoe

Realmente não tenho exemplos, é exatamente o que planejo fazer quando tiver tempo. Vou adicionar mais algumas das classes gerais e ver se isso ajuda. Existe isso e isso . é mais sobre classes de objeto do que como elas se relacionam ou a renderização. Como eu não o implementei, pode haver armadilhas, bits que precisam ser trabalhados ou com desempenho, mas acho que a estrutura geral está correta.
David C. Bishop
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.