Como evitar dependências circulares entre Player e World?

60

Estou trabalhando em um jogo 2D onde você pode mover para cima, baixo, esquerda e direita. Eu tenho essencialmente dois objetos de lógica de jogo:

  • Jogador: Tem uma posição em relação ao mundo
  • Mundo: desenha o mapa e o jogador

Até agora, o mundo depende do jogador (ou seja, tem uma referência a ele), precisando de sua posição para descobrir onde desenhar o personagem do jogador e qual parte do mapa desenhar.

Agora, quero adicionar a detecção de colisão para tornar impossível o jogador se mover através das paredes.

A maneira mais simples de pensar é pedir ao jogador que pergunte ao mundo se o movimento pretendido é possível. Mas isso introduziria uma dependência circular entre o Player e o World (ou seja, cada um mantém uma referência ao outro), o que parece evitar. A única maneira de inventar é fazer o mundo mover o jogador , mas acho isso pouco intuitivo.

Qual é minha melhor opção? Ou evitar uma dependência circular não vale a pena?

futlib
fonte
4
Por que você acha que uma dependência circular é uma coisa ruim? stackoverflow.com/questions/1897537/…
Fuhrmanator 14/11
@ Fuhrmanator Eu não acho que eles geralmente sejam uma coisa ruim, mas eu teria que tornar as coisas um pouco mais complexas no meu código para apresentar uma.
Futlib #
I louco um post sobre a nossa pequena conversa, nada de novo, porém: yannbane.com/2012/11/... ...
jcora

Respostas:

61

O mundo não deve se desenhar; o renderizador deve desenhar o mundo. O jogador não deve se desenhar; o renderizador deve desenhar o jogador em relação ao mundo.

O jogador deve perguntar ao mundo sobre detecção de colisão; ou talvez as colisões devam ser tratadas por uma classe separada que verifique a detecção de colisões não apenas contra o mundo estático, mas também contra outros atores.

Eu acho que o mundo provavelmente não deveria estar ciente do jogador; deve ser um primitivo de baixo nível, não um objeto divino. O Player provavelmente precisará invocar alguns métodos do Mundo, talvez indiretamente (detecção de colisão ou verificação de objetos interativos, etc.).

Liosan
fonte
25
@ snake5 - Há uma diferença entre "pode" e "deveria". Qualquer coisa pode desenhar qualquer coisa - mas quando você precisa alterar o código que trata do desenho, é muito mais fácil ir para a classe "Renderer" em vez de procurar o "Anything" que está desenhando. "obcecado com compartimentalização" é outra palavra para "coesão".
Nate
16
@ Mr.Beast, não, ele não é. Ele está defendendo um bom design. Empilhar tudo em um erro de uma classe não faz sentido.
jcora
23
Uau, eu não pensei que isso provocaria uma reação assim :) Não tenho nada a acrescentar à resposta, mas posso explicar por que a dei - porque acho que é mais simples. Não é 'adequado' ou 'correto'. Eu não queria que soasse assim. É mais simples para mim, porque se me vejo lidando com classes com muitas responsabilidades, uma divisão é mais rápida do que forçar a leitura do código existente. Eu gosto de código em partes que eu possa entender e refatorar em reação a problemas como o que o @futlib está enfrentando.
Liosan #
12
@ snake5 Dizer que adicionar mais classes adiciona uma sobrecarga para o programador geralmente está completamente errado na minha experiência. Na minha opinião, as classes de linha 10x100 com nomes informativos e responsabilidades bem definidas são mais fáceis de ler e menos sobrecarga para o programador do que uma única classe de Deus de 1000 linhas.
Martin
7
Como uma observação sobre o que desenha o que, Rendereré necessário algum tipo, mas isso não significa que a lógica de como cada coisa é renderizada é tratada pelo Renderer, cada coisa que precisa ser desenhada provavelmente deve herdar de uma interface comum como IDrawableou IRenderable(ou interface equivalente em qualquer idioma que você esteja usando). O mundo poderia ser Renderer, suponho, mas isso parece estar ultrapassando sua responsabilidade, especialmente se já fosse um IRenderable.
zzzzBov
35

Aqui está como um mecanismo de renderização típico lida com essas coisas:

Há uma distinção fundamental entre onde um objeto está no espaço e como ele é desenhado.

  1. Desenhando um objeto

    Você normalmente tem uma classe Renderer que faz isso. Simplesmente pega um objeto (Modelo) e desenha na tela. Pode ter métodos como drawSprite (Sprite), drawLine (..), drawModel (Model), o que você quiser. É um renderizador, portanto, ele deve fazer todas essas coisas. Ele também usa qualquer API existente abaixo, para que você possa ter, por exemplo, um renderizador que usa OpenGL e outro que usa DirectX. Se você deseja portar seu jogo para outra plataforma, basta escrever um novo renderizador e usá-lo. É tão fácil.

  2. Movendo um objeto

    Cada objeto é anexado a algo que gostamos de chamar de SceneNode . Você consegue isso através da composição. Um SceneNode contém um objeto. É isso aí. O que é um SceneNode? É uma classe simples que contém todas as transformações (posição, rotação, escala) de um objeto (geralmente relativas a outro SceneNode) junto com o objeto real.

  3. Gerenciando os objetos

    Como os SceneNodes são gerenciados? Através de um SceneManager . Esta classe cria e acompanha todos os SceneNode da sua cena. Você pode solicitar um SceneNode específico (geralmente identificado por um nome de sequência como "Player" ou "Table") ou uma lista de todos os nós.

  4. Desenhando o mundo

    Isso deve estar bem óbvio agora. Basta percorrer todos os SceneNode da cena e fazer com que o Renderer o desenhe no lugar certo. Você pode desenhá-lo no lugar certo, solicitando que o renderizador armazene as transformações de um objeto antes de renderizá-lo.

  5. Detecção de colisão

    Isso nem sempre é trivial. Geralmente, você pode consultar a cena sobre qual objeto está em um determinado ponto no espaço ou quais objetos um raio cruzará. Dessa forma, você pode criar um raio do seu jogador na direção do movimento e perguntar ao gerente de cena qual é o primeiro objeto que o raio cruza. Você pode então escolher mover o jogador para a nova posição, movê-lo em uma quantidade menor (para aproximá-lo do objeto em colisão) ou não movê-lo. Certifique-se de que essas consultas sejam tratadas por classes separadas. Eles devem solicitar ao SceneManager uma lista de SceneNodes, mas é outra tarefa determinar se esse SceneNode cobre um ponto no espaço ou se cruza com um raio. Lembre-se de que o SceneManager apenas cria e armazena nós.

Então, qual é o jogador e o que é o mundo?

O Player pode ser uma classe que contém um SceneNode, que por sua vez contém o modelo a ser renderizado. Você move o player alterando a posição do nó da cena. O mundo é simplesmente uma instância do SceneManager. Ele contém todos os objetos (por meio de SceneNodes). Você lida com a detecção de colisão fazendo consultas no estado atual da cena.

Isso está longe de ser uma descrição completa ou precisa do que acontece na maioria dos mecanismos, mas deve ajudar a entender os fundamentos e por que é importante respeitar os princípios de POO sublinhados pelo SOLID . Não se resigne à idéia de que é muito difícil reestruturar seu código ou que isso realmente não vai ajudá-lo. Você ganhará muito mais no futuro projetando cuidadosamente seu código.

lugar das raízes
fonte
+1 - Eu me encontrei construindo meus sistemas de jogo mais ou menos assim, e acho que é bastante flexível.
Cypher
+1, ótima resposta. Mais concreto e direto ao ponto do que o meu.
jcora
+1, eu aprendi muito com essa resposta e até teve um final inspirador. Obrigado @rootlocus
joslinm
16

Por que você quer evitar isso? Dependências circulares devem ser evitadas se você deseja criar uma classe reutilizável. Mas o jogador não é uma classe que precisa ser reutilizável. Você gostaria de usar o Player sem um mundo? Provavelmente não.

Lembre-se de que as classes nada mais são do que coleções de funcionalidades. A questão é como dividir a funcionalidade. Faça o que for necessário. Se você precisar de uma decadência circular, que assim seja. (O mesmo vale para todos os recursos de OOP, a propósito. Codifique as coisas de uma maneira que sirva a um propósito, não siga apenas paradigmas às cegas.)

Editar
Ok, para responder à pergunta: você pode evitar que o jogador precise conhecer o mundo para verificações de colisão usando retornos de chamada:

World::checkForCollisions()
{
  [...]
  foreach(entityA in entityList)
    foreach(entityB in entityList)
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
}

Player::onCollision(other)
{
  [... react on the collision ...]
}

O tipo de física que você descreveu na pergunta pode ser tratado pelo mundo se você expor a velocidade das entidades:

World::calculatePhysics()
{ 
  foreach(entityA in entityList)
    foreach(entityB in entityList)
    {
      [... move entityA according to its velocity as far as possible ...]
      if([... entityA has collided with the world ...])
         entityA.onWorldCollision();
      [... calculate the movement of entityB in order to know if A has collided with B ...]
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
    }
}

No entanto, note que você provavelmente precisará de uma dependência do mundo, mais cedo ou mais tarde, ou seja, sempre que precisar da funcionalidade do mundo: deseja saber onde está o inimigo mais próximo? Você quer saber a que distância está a próxima borda? Dependência é.

API-Beast
fonte
4
A dependência circular +1 não é realmente um problema aqui. Nesta fase, não há razão para se preocupar com isso. Se o jogo crescer e o código amadurecer, provavelmente será uma boa idéia refatorar as classes Player e World em subclasses de qualquer maneira, ter um sistema baseado em componentes adequado, classes para manipulação de entrada, talvez Rendered, etc. um começo, não há problema.
Laurent Couvidou
4
-1, essa definitivamente não é a única razão para não introduzir dependências circulares. Ao não apresentá-los, você torna seu sistema mais fácil de estender e alterar.
Jcora #
4
@ Bane Você não pode codificar nada sem essa cola. A diferença é quanto indireto você adiciona. Se você tiver as classes Jogo -> Mundo -> Entidade ou se tiver as classes Jogo -> Mundo, SoundManager, InputManager, PhysicsEngine, ComponentManager. Isso torna as coisas menos legíveis por causa de toda a sobrecarga (sintática) e da complexidade implícita. E, a certa altura, você precisará dos componentes para interagir entre si. E é nesse ponto que uma classe de cola facilita as coisas do que tudo dividido entre muitas classes.
API-Beast
3
Não, você está movendo os postes. Claro que algo deve chamar render(World). O debate é sobre se todo o código deve estar dentro de uma classe ou se o código deve ser dividido em unidades lógicas e funcionais, que são mais fáceis de manter, estender e gerenciar. BTW, boa sorte em reutilizar esses gerenciadores de componentes, mecanismos de física e gerenciadores de entrada, todos inteligentemente indiferenciados e completamente acoplados.
jcora
11
@Bane Existem outras maneiras de dividir as coisas em partes lógicas do que a introdução de novas classes, btw. Você também pode adicionar novas funções ou dividir seus arquivos em várias seções separadas por blocos de comentários. Apenas mantê-lo simples não significa que o código será uma bagunça.
API-Beast
13

Seu design atual parece ir contra o primeiro princípio do design SOLID .

Esse primeiro princípio, chamado de "princípio de responsabilidade única", geralmente é uma boa diretriz a seguir, a fim de não criar objetos monolíticos e práticos, que sempre prejudicarão seu design.

Para concretizar, seu Worldobjeto é responsável por atualizar e manter o estado do jogo e por desenhar tudo.

E se o seu código de renderização mudar / precisar mudar? Por que você deveria atualizar as duas classes que realmente não têm nada a ver com a renderização? Como o Liosan já disse, você deve ter um Renderer.


Agora, para responder sua pergunta real ...

Existem muitas maneiras de fazer isso, e essa é apenas uma maneira de dissociar:

  1. O mundo não sabe o que é um jogador.
    • No entanto, ele possui uma lista de Objects na qual o jogador está localizado, mas não depende da classe do jogador (use herança para conseguir isso).
  2. O jogador é atualizado por alguns InputManager.
  3. O mundo lida com a detecção de movimento e colisão, aplicando alterações físicas apropriadas e enviando atualizações aos objetos.
    • Por exemplo, se o objeto A e o objeto B colidirem, o mundo os informará e eles poderão lidar com isso sozinhos.
    • O mundo ainda lidaria com a física (se o seu design for assim).
    • Então, os dois objetos puderam ver se a colisão lhes interessa ou não. Por exemplo, se o objeto A era o jogador e o objeto B era um pico, então o jogador poderia causar dano a si mesmo.
    • Isso pode ser resolvido de outras maneiras, no entanto.
  4. O Rendererdesenha todos os objetos.
jcora
fonte
Você diz que o mundo não sabe o que é um jogador, mas lida com a detecção de colisões que podem precisar conhecer as propriedades do jogador, se ele é um dos objetos que colidem.
Markus von Broady
Herança, o mundo deve estar ciente de algum tipo de objeto, que pode ser descrito de uma maneira geral. O problema não é que o mundo tenha apenas uma referência ao jogador, mas que ele dependa dele como uma classe (ou seja, use campos como o healthque somente esta instância Playerpossui).
jcora
Ah, você quer dizer que o mundo não tem referência ao player, apenas possui uma variedade de objetos implementando a interface ICollidable, juntamente com o player, se necessário.
Markus von Broady
2
+1 boa resposta. Mas: "ignore todas as pessoas que dizem que um bom design de software não é importante". Comum. Ninguém disse isso.
Laurent Couvidou 13/11
2
Editado! Ele parecia tipo de desnecessária de qualquer maneira ...
jcora
1

O jogador deve perguntar ao mundo sobre coisas como detecção de colisão. A maneira de evitar a dependência circular é não ter o mundo dependente do Player. O mundo precisa saber para onde está se desenhando: você provavelmente quer isso mais distante, talvez com uma referência a um objeto Camera que, por sua vez, possa conter uma referência a alguma entidade a ser rastreada.

O que você deseja evitar em termos de referências circulares não é tanto manter referências um ao outro, mas sim se referir um ao outro explicitamente no código.

Tom Johnson
fonte
1

Sempre que dois tipos diferentes de objetos podem se perguntar. Eles dependerão um do outro, pois precisam manter uma referência ao outro para chamar seus métodos.

Você pode evitar a dependência circular fazendo o Mundo perguntar ao Jogador, mas o Jogador não pode perguntar ao Mundo, ou vice-versa. Desta forma, o mundo tem referências aos jogadores, mas os jogadores não precisam de referência ao mundo. Ou vice-versa. Mas isso não resolverá o problema, porque o Mundo precisaria perguntar aos jogadores se eles têm algo a perguntar e dizer na próxima ligação ...

Portanto, você não pode realmente solucionar esse "problema" e acho que não há necessidade de se preocupar com isso. Mantenha o design estúpido e simples o máximo que puder.

Calmarius
fonte
0

Retirando os detalhes sobre jogador e mundo, você tem um caso simples de não querer introduzir uma dependência circular entre dois objetos (que dependendo do seu idioma pode nem importar, veja o link no comentário do Fuhrmanator). Existem pelo menos duas soluções estruturais muito simples que se aplicariam a este e a problemas semelhantes:

1) Introduza o padrão singleton em sua classe mundial . Isso permitirá que o jogador (e qualquer outro objeto) encontre facilmente o objeto mundial sem pesquisas caras ou links permanentemente retidos. A essência desse padrão é apenas que a classe tem uma referência estática à única instância dessa classe, que é definida na instanciação do objeto e limpa na exclusão.

Dependendo da sua linguagem de desenvolvimento e da complexidade que você deseja, você pode facilmente implementá-la como uma superclasse ou interface e reutilizá-la para muitas classes principais das quais você não espera ter mais de uma no seu projeto.

2) Se o idioma em que você está desenvolvendo o suportar (muitos o fazem), use uma referência fraca . Esta é uma referência que não afeta coisas como coleta de lixo. É útil exatamente nesses casos, mas não faça suposições sobre se o objeto que você está referenciando fraco ainda existe.

No seu caso particular, o (s) seu (s) jogador (es) pode (m) conter uma referência fraca ao mundo. O benefício disso (como no singleton) é que você não precisa procurar o objeto mundial de alguma forma em cada quadro ou ter uma referência permanente que dificultará os processos afetados por referências circulares, como coleta de lixo.

FlintZA
fonte
0

Como os outros disseram, acho que seu Worldestá fazendo uma coisa demais: ele está tentando ambos contêm o jogo Map(que deve ser uma entidade distinta) e ser um Rendererao mesmo tempo.

Portanto, crie um novo objeto (chamado GameMap, possivelmente) e armazene os dados no nível do mapa. Escreva funções nele que interagem com o mapa atual.

Então você também precisa de um Rendererobjeto. Você pode fazer desse Rendererobjeto a coisa que contém GameMap e Player(assim como Enemies) e também os desenha.

bobobobo
fonte
-6

Você pode evitar dependências circulares não adicionando as variáveis ​​como membros. Use uma função estática CurrentWorld () para o player ou algo parecido. Porém, não invente uma interface diferente daquela implementada no World, isso é completamente desnecessário.

Também é possível destruir a referência antes / durante a destruição do objeto player para efetivamente parar os problemas causados ​​por referências circulares.

snake5
fonte
11
Estou contigo. OOP está superestimado. Os tutoriais e a educação saltam rapidamente para o OO depois de aprender o material básico do fluxo de controle. Os programas OO geralmente são mais lentos que o código processual, porque há burocracia entre seus objetos, você tem muitos acessos de ponteiro, o que causa uma carga de erros de cache. Seu jogo funciona, mas muito lento. Os jogos reais, muito rápidos e ricos em recursos, usando matrizes globais simples e funções otimizadas manualmente, otimizadas para tudo, para evitar falhas de cache. O que pode resultar em um aumento de dez vezes no desempenho.
Calmarius