Pergunta sobre arquitetura / design de jogos - construindo um mecanismo eficiente e evitando instâncias globais (jogo em C ++)

28

Eu tinha uma pergunta sobre arquitetura de jogos: Qual é a melhor maneira de fazer com que diferentes componentes se comuniquem?

Peço desculpas se essa pergunta já foi feita um milhão de vezes, mas não consigo encontrar nada com exatamente o tipo de informação que estou procurando.

Eu tenho tentado construir um jogo a partir do zero (C ++, se for o caso) e observei alguns softwares de jogos de código aberto como inspiração (Super Maryo Chronicles, OpenTTD e outros). Percebo que muitos desses designs de jogos usam instâncias globais e / ou singletons em todo o lugar (para coisas como filas de renderização, gerenciadores de entidades, gerenciadores de vídeo etc.). Estou tentando evitar instâncias globais e singletons e construindo um mecanismo o mais fracamente possível, mas estou encontrando alguns obstáculos que devem à minha inexperiência em um design eficaz. (Parte da motivação para este projeto é abordar isso :))

Eu criei um design no qual tenho um GameCoreobjeto principal que possui membros análogos às instâncias globais que vejo em outros projetos (ou seja, ele possui um gerenciador de entrada, um gerenciador de vídeo, um GameStageobjeto que controla todas as entidades e o jogo para qualquer estágio atualmente carregado, etc). O problema é que, como tudo é centralizado no GameCoreobjeto, não tenho uma maneira fácil de diferentes componentes se comunicarem.

Olhando para o Super Maryo Chronicles, por exemplo, sempre que um componente do jogo precisa se comunicar com outro componente (ou seja, um objeto inimigo quer se adicionar à fila de renderização a ser desenhada no estágio de renderização), ele apenas conversa com o instância global.

Para mim, preciso que meus objetos de jogo passem informações relevantes de volta ao GameCoreobjeto, para que o GameCoreobjeto possa passar essas informações para o (s) outro (s) componente (s) do sistema que precisa (por exemplo: para a situação acima, cada objeto inimigo passaria suas informações de renderização de volta para o GameStageobjeto, que coletaria tudo e passaria para GameCore, o que por sua vez passaria para o gerenciador de vídeo para renderização). Parece um design realmente horrível, e eu estava tentando pensar em uma solução para isso. Meus pensamentos sobre possíveis projetos:

  1. Instâncias globais (design de Super Maryo Chronicles, OpenTTD, etc)
  2. Ter o GameCoreobjeto como intermediário através do qual todos os objetos se comunicam (design atual descrito acima)
  3. Forneça ponteiros de componentes para todos os outros componentes com os quais eles precisarão conversar (por exemplo, no exemplo Maryo acima, a classe inimiga teria um ponteiro para o objeto de vídeo com o qual precisa conversar)
  4. Divida o jogo em subsistemas - Por exemplo, tenha objetos gerenciadores no GameCoreobjeto que manipulem a comunicação entre objetos em seu subsistema
  5. (Outras opções? ....)

Imagino que a opção 4 acima seja a melhor solução, mas estou tendo alguns problemas para projetá-la ... talvez porque tenha pensado em termos dos projetos que vi que usam globais. Parece que estou pegando o mesmo problema existente no meu design atual e replicando-o em cada subsistema, apenas em uma escala menor. Por exemplo, o GameStageobjeto descrito acima é uma tentativa de fazer isso, mas o GameCoreobjeto ainda está envolvido no processo.

Alguém pode oferecer algum conselho de design aqui?

Obrigado!

Awesomania
fonte
1
Entendo seu instinto de que singletons não são um ótimo design. Na minha experiência, eles têm sido a maneira mais simples de gerenciar a comunicação entre sistemas
Emmett Butler
4
Adicionando como comentário, pois não sei se é uma prática recomendada. Eu tenho um GameManager central que é composto por subsistemas como InputSystem, GraphicsSystem, etc. Cada subsistema usa o GameManager como um parâmetro no construtor e armazena a referência a um membro privado da classe. Nesse ponto, eu posso me referir a qualquer outro sistema acessando-o através da referência do GameManager.
Inisheer
Alterei as tags porque esta pergunta é sobre código, não sobre design de jogos.
Klaim
esse tópico é um pouco antigo, mas tenho exatamente o mesmo problema. Eu uso o OGRE e tento usar da melhor maneira, na minha opinião a opção 4 é a melhor abordagem. Criei algo como o Advanced Ogre Framework, mas isso não é muito modular. Eu acho que preciso de uma manipulação de entrada do subsistema que apenas obtenha os hits do teclado e os movimentos do mouse. O que eu não entendo é: como posso criar um gerente de "comunicação" entre os subsistemas?
Dominik2000
1
Olá @ Dominik2000, este é um site de perguntas e respostas, não um fórum. Se você tiver uma pergunta, deve postar uma pergunta real e não uma resposta a uma existente. Veja o FAQ para mais detalhes.
21713 Josh

Respostas:

19

Algo que usamos em nossos jogos para organizar nossos dados globais é o padrão de design do ServiceLocator . A vantagem desse padrão comparado ao padrão Singleton é que a implementação de seus dados globais pode mudar durante o tempo de execução do aplicativo. Além disso, seus objetos globais também podem ser alterados durante o tempo de execução. Outra vantagem é que é mais fácil gerenciar a ordem de inicialização de seus objetos globais, o que é muito importante, especialmente em C ++.

por exemplo (código C # que pode ser facilmente traduzido para C ++ ou Java)

Digamos que você tenha uma interface de back-end de renderização que possui algumas operações comuns para renderizar coisas.

public interface IRenderBackend
{
    void Draw();
}

E que você tenha a implementação de back-end de renderização padrão

public class DefaultRenderBackend : IRenderBackend
{
    public void Draw()
    {
        //do default rendering stuff.
    }
}

Em alguns projetos, parece legítimo poder acessar o back-end de renderização globalmente. No padrão Singleton, isso significa que cada implementação do IRenderBackend deve ser implementada como instância global exclusiva. Mas o uso do padrão ServiceLocator não exige isso.

Aqui está como:

public class ServiceLocator<T>
{
    private static T currGlobalInstance;

    public static T Service
    {
        get { return currGlobalInstance; }
        set { currGlobalInstance = value; }
    }
}

Para poder acessar seu objeto global, você precisa inicializá-lo primeiro.

//somewhere during program initialization
ServiceLocator<IRenderBackend>.Service = new DefaultRenderBackend();

//somewhere else in the code
IRenderBackend currentRenderBackend = ServiceLocator<IRenderBackend>.Service;

Apenas para demonstrar como as implementações podem variar durante o tempo de execução, digamos que seu jogo tenha um minijogo em que a renderização é isométrica e você implemente um IsometricRenderBackend .

public class IsometricRenderBackend : IRenderBackend
{
    void draw()
    {
        //do rendering using an isometric view
    }
}

Quando você faz a transição do estado atual para o estado do minijogo, basta alterar o backend de renderização global fornecido pelo localizador de serviço.

ServiceLocator<IRenderBackend>.Service = new IsometricRenderBackend();

Outra vantagem é que você também pode usar serviços nulos. Por exemplo, se tivéssemos um serviço ISoundManager e o usuário quisesse desligar o som, poderíamos simplesmente implementar um NullSoundManager que não faz nada quando seus métodos são chamados; portanto, definindo o objeto de serviço do ServiceLocator como um objeto NullSoundManager, podemos obter este resultado com quase nenhuma quantidade de trabalho.

Para resumir, às vezes pode ser impossível eliminar dados globais, mas isso não significa que você não possa organizá-los adequadamente e de maneira orientada a objetos.

vdaras
fonte
Eu já examinei isso antes, mas não o implementei em nenhum dos meus projetos. Desta vez, pretendo. Obrigado :)
Awesomania
3
@ Erevis Então, basicamente, você está descrevendo uma referência global ao objeto polimórfico. Por sua vez, isso é apenas uma dupla indireção (ponteiro -> interface -> implementação). Em C ++, pode ser implementado facilmente como std::unique_ptr<ISomeService>.
Shadows In Rain
1
Você pode alterar a estratégia de inicialização para "inicializar no primeiro acesso" e evitar a necessidade de alguma sequência de código externa alocar e enviar serviços para o localizador. Você pode adicionar uma lista "depende de" aos serviços, para que, quando alguém for inicializado, configure automaticamente outros serviços necessários e não reze para que alguém se lembre de fazer isso no main.cpp. Uma boa resposta com flexibilidade para ajustes futuros.
Patrick Hughes
4

Existem muitas maneiras de projetar um mecanismo de jogo e tudo se resume à preferência.

Para tirar o básico do caminho, alguns desenvolvedores preferem projetá-lo como uma pirâmide, onde há algumas classes principais, geralmente chamadas de kernel, core ou framework que cria, possui e inicializa uma série de subsistemas, como como áudio, gráficos, rede, física, IA e gerenciamento de tarefas, entidades e recursos. Geralmente, esses subsistemas são expostos a você por essa classe de estrutura e, geralmente, você passaria essa classe de estrutura para suas próprias classes como um argumento construtor, quando apropriado.

Acredito que você esteja no caminho certo, pensando na opção 4.

Lembre-se de que, quando se trata de comunicação, nem sempre isso implica em uma chamada de função direta. Existem várias maneiras indiretas de comunicação, seja através de algum método indireto usando Signal and Slotsou usando Messages.

Às vezes, nos jogos, é importante permitir que as ações ocorram de forma assíncrona para manter o ciclo do jogo o mais rápido possível, para que as taxas de quadros sejam fluidas a olho nu. Os jogadores não gostam de cenas lentas e agitadas e, portanto, temos que encontrar maneiras de manter as coisas fluindo para eles, mas mantendo a lógica fluindo, mas sob controle e ordenada também. Embora as operações assíncronas tenham seu lugar, elas também não são a resposta para todas as operações.

Saiba que você terá um mix de comunicações síncronas e assíncronas. Escolha o que for apropriado, mas saiba que você precisará suportar os dois estilos entre seus subsistemas. A criação de suporte para ambos o servirá bem no futuro.

Naros
fonte
1

Você só precisa garantir que não haja dependências reversas ou cíclicas. Por exemplo, se você tem uma classe Core, e esta Corepossui uma Levele a Levellista de Entity, a árvore de dependência deve se parecer com:

Core --> Level --> Entity

Portanto, dada essa árvore de dependência inicial, você nunca deve Entitydepender de Levelou Core, e Levelnunca deve depender de Core. Se um Levelou Entityprecisar acessar dados mais altos na árvore de dependências, eles devem ser passados ​​como parâmetro por referência.

Considere o seguinte código (C ++):

class Core;
class Entity;
class Level;

class Level
{
    public:
        Level(Core& coreIn) : core(coreIn) {}

        Core& core;
}

class Entity
{
    public:
        Entity(Level& levelIn) : level(levelIn) {}

        Level& level;
}

Usando esta técnica, você pode ver que cada um Entitytem acesso à Level, ea Leveltem acesso ao Core. Observe que cada Entityum armazena uma referência ao mesmo Level, desperdiçando memória. Ao perceber isso, você deve questionar se cada um Entityrealmente precisa de acesso ao Level.

Na minha experiência, existe A) Uma solução realmente óbvia para evitar dependências reversas, ou B) Não há maneira possível de evitar instâncias globais e singletons.

Maçãs
fonte
Estou esquecendo de algo? Você menciona 'você nunca deve ter uma Entidade depende do Nível', mas depois descreve o ctor como 'Entidade (Nível e nívelIn)'. Entendo que a dependência é passada por ref, mas ainda é uma dependência.
Adam Naylor
@AdamNaylor O ponto é que, às vezes, você realmente precisa de dependências reversas e pode evitar globais passando referências. Em geral, porém, é melhor evitar completamente essas dependências e nem sempre é claro como fazer isso.
Maçãs
0

Então, basicamente, você deseja evitar um estado mutável global ? Você pode torná-lo local, imutável ou não um estado. Mais tarde é mais eficiente e flexível. É conhecido como ocultação de implementação.

class ISomeComponent // abstract base class
{
    //...
};

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Sombras na chuva
fonte
0

A questão é, na verdade, sobre como reduzir o acoplamento sem sacrificar o desempenho. Todos os objetos globais (serviços) geralmente formam um tipo de contexto que é mutável durante o tempo de execução do jogo. Nesse sentido, o padrão do localizador de serviço dispersa diferentes partes do contexto em diferentes partes do aplicativo, que podem ou não ser o que você deseja. Outra abordagem do mundo real seria declarar uma estrutura como esta:

struct sEnvironment
{
    owning<iAudio*> m_Audio;
    owning<iRenderer*> m_Renderer;
    owning<iGameLevel*> m_GameLevel;
    ...
}

E passe-o como um ponteiro bruto não proprietário sEnvironment*. Aqui, os ponteiros apontam para interfaces para que o acoplamento seja reduzido de maneira semelhante em comparação com o localizador de serviço. No entanto, todos os serviços estão em um só lugar (o que pode ou não ser bom). Esta é apenas outra abordagem.

Sergey K.
fonte