¿Cómo evitar dependencias circulares entre Player y World?

60

Estoy trabajando en un juego 2D en el que puedes moverte hacia arriba, hacia abajo, hacia la izquierda y hacia la derecha. Tengo esencialmente dos objetos lógicos del juego:

  • Jugador: tiene una posición relativa al mundo
  • Mundo: dibuja el mapa y el jugador

Hasta ahora, el mundo depende del jugador (es decir, tiene una referencia a él), necesitando su posición para determinar dónde dibujar el personaje del jugador y qué parte del mapa dibujar.

Ahora quiero agregar detección de colisión para que sea imposible que el jugador se mueva a través de las paredes.

La forma más simple en la que puedo pensar es que el jugador le pregunte al mundo si el movimiento previsto es posible. Pero eso introduciría una dependencia circular entre Player y World (es decir, cada uno tiene una referencia al otro), lo que parece evitar. La única forma en que se me ocurrió es hacer que el Mundo moviera al Jugador , pero eso me parece poco intuitivo.

¿Cuál es mi mejor opción? ¿O no vale la pena evitar una dependencia circular?

futlib
fuente
44
¿Por qué crees que una dependencia circular es algo malo? stackoverflow.com/questions/1897537/…
Fuhrmanator
@Fuhrmanator No creo que en general sean algo malo, pero tendría que hacer las cosas un poco más complejas en mi código para introducir una.
futlib
Me enojó una publicación sobre nuestra pequeña discusión, sin embargo, nada nuevo: yannbane.com/2012/11/… ...
jcora

Respuestas:

61

El mundo no debe dibujarse a sí mismo; el Renderer debería dibujar el mundo. El jugador no debe dibujarse solo; el Renderer debe dibujar al jugador en relación con el mundo.

El jugador debe preguntarle al mundo sobre la detección de colisiones; o tal vez las colisiones deberían ser manejadas por una clase separada que verificaría la detección de colisiones no solo contra el mundo estático sino también contra otros actores.

Creo que el mundo probablemente no debería estar al tanto del jugador; debería ser un primitivo de bajo nivel, no un objeto divino. El jugador probablemente necesitará invocar algunos métodos del mundo, tal vez indirectamente (detección de colisión o comprobación de objetos interactivos, etc.).

Liosan
fuente
25
@ snake5: hay una diferencia entre "can" y "should". Cualquier cosa puede dibujar cualquier cosa, pero cuando necesita cambiar el código que se ocupa del dibujo, es mucho más fácil ir a la clase "Renderer" en lugar de buscar el "Anything" que está dibujando. "obsesionarse con la compartimentación" es otra palabra para "cohesión".
Nate
16
@ Mr.Beast, no, no lo es. Él aboga por un buen diseño. Meter todo en un error de clase no tiene sentido.
jcora
23
Whoa, no pensé que provocaría tal reacción :) No tengo nada que agregar a la respuesta, pero puedo explicar por qué lo di, porque creo que es más simple. No es "correcto" o "correcto". No quería que sonara así. Para mí es más simple porque si me encuentro abordando clases con demasiadas responsabilidades, una división es más rápida que forzar la lectura del código existente. Me gusta el código en fragmentos que puedo entender y refactorizar en reacción a problemas como el que está experimentando @futlib.
Liosan el
12
@ snake5 Decir que agregar más clases agrega gastos generales para el programador a menudo es completamente erróneo en mi experiencia. En mi opinión, las clases de línea de 10x100 con nombres informativos y responsabilidades bien definidas son más fáciles de leer y menos costos para el programador que una sola clase de dios de 1000 líneas.
Martin
77
Como una nota sobre qué dibuja qué, Rendereres necesario un tipo de, pero eso no significa que la lógica de cómo se procesa cada cosa es manejada por Renderer, cada cosa que debe dibujarse probablemente debe heredar de una interfaz común como IDrawableo IRenderable(o equivalente de interfaz en cualquier idioma que esté usando). El mundo podría ser el Renderer, supongo, pero eso parece que estaría sobrepasando su responsabilidad, especialmente si ya era un IRenderablesí mismo.
zzzzBov
35

Así es como un motor de renderizado típico maneja estas cosas:

Hay una distinción fundamental entre dónde está un objeto en el espacio y cómo se dibuja el objeto.

  1. Dibujar un objeto

    Por lo general, tiene una clase Renderer que hace esto. Simplemente toma un objeto (Modelo) y dibuja en la pantalla. Puede tener métodos como drawSprite (Sprite), drawLine (..), drawModel (Model), lo que sea que necesite. Es un Renderer, así que se supone que debe hacer todas estas cosas. También usa cualquier API que tenga debajo, por lo que puede tener, por ejemplo, un procesador que use OpenGL y uno que use DirectX. Si desea portar su juego a otra plataforma, simplemente escriba un nuevo renderizador y use ese. Es fácil.

  2. Mover un objeto

    Cada objeto está unido a algo a lo que nos referimos como SceneNode . Lo logras a través de la composición. Un SceneNode contiene un objeto. Eso es. ¿Qué es un SceneNode? Es una clase simple que contiene todas las transformaciones (posición, rotación, escala) de un objeto (generalmente en relación con otro SceneNode) junto con el objeto real.

  3. Administrar los objetos

    ¿Cómo se gestionan los SceneNodes? A través de un SceneManager . Esta clase crea y realiza un seguimiento de cada SceneNode en su escena. Puede pedirle un SceneNode específico (generalmente identificado por un nombre de cadena como "Player" o "Table") o una lista de todos los nodos.

  4. Dibujando el mundo

    Esto debería ser bastante obvio por ahora. Simplemente camine por cada SceneNode en la escena y haga que el Renderer lo dibuje en el lugar correcto. Puede dibujarlo en el lugar correcto haciendo que el procesador almacene las transformaciones de un objeto antes de representarlo.

  5. Detección de colisiones

    Esto no siempre es trivial. Por lo general, puede consultar la escena sobre qué objeto está en un determinado punto del espacio o qué objetos se intersectarán con un rayo. De esta manera, puede crear un rayo desde su reproductor en la dirección del movimiento y preguntarle al administrador de la escena cuál es el primer objeto que se cruza con el rayo. Luego puede elegir mover al jugador a la nueva posición, moverlo en una cantidad menor (para colocarlo al lado del objeto que colisiona) o no moverlo en absoluto. Asegúrese de que estas consultas sean manejadas por clases separadas. Deberían pedirle al SceneManager una lista de SceneNodes, pero es otra tarea determinar si ese SceneNode cubre un punto en el espacio o se cruza con un rayo. Recuerde que el SceneManager solo crea y almacena nodos.

Entonces, ¿qué es el jugador y qué es el mundo?

El jugador podría ser una clase que contiene un SceneNode, que a su vez contiene el modelo que se representará. Mueves al jugador cambiando la posición del nodo de escena. El mundo es simplemente una instancia del SceneManager. Contiene todos los objetos (a través de SceneNodes). Maneja la detección de colisiones haciendo consultas sobre el estado actual de la escena.

Esto está lejos de ser una descripción completa o precisa de lo que sucede dentro de la mayoría de los motores, pero debería ayudarlo a comprender los fundamentos y por qué es importante respetar los principios OOP subrayados por SOLID . No se resigne a la idea de que es demasiado difícil reestructurar su código o que realmente no lo ayudará. Ganará mucho más en el futuro diseñando cuidadosamente su código.

rootlocus
fuente
+1: me he encontrado construyendo mis sistemas de juego de esta manera, y creo que es bastante flexible.
Cypher
+1, gran respuesta. Más concreto y al punto que el mío.
jcora
+1, aprendí mucho de esta respuesta e incluso tuvo un final inspirador. Gracias @rootlocus
joslinm
16

¿Por qué querrías evitar eso? Deben evitarse las dependencias circulares si desea crear una clase reutilizable. Pero el jugador no es una clase que deba ser reutilizable. ¿Alguna vez querrías usar el reproductor sin un mundo? Probablemente no.

Recuerde que las clases no son más que colecciones de funcionalidad. La pregunta es cómo se divide la funcionalidad. Haz lo que necesites hacer. Si necesita una decadencia circular, que así sea. (Lo mismo ocurre con cualquier característica de OOP por cierto. Codifique las cosas de una manera que tenga un propósito, no solo siga los paradigmas a ciegas).

Edite
Bien, para responder la pregunta: puede evitar que el Jugador necesite conocer el mundo para las comprobaciones de colisión mediante devoluciones de llamada:

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 ...]
}

El mundo puede manejar el tipo de física que ha descrito en la pregunta si expone la velocidad de las 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);
    }
}

Sin embargo, tenga en cuenta que probablemente necesitará una dependencia del mundo tarde o temprano, es decir, cuando necesite la funcionalidad del mundo: ¿quiere saber dónde está el enemigo más cercano? ¿Quieres saber qué tan lejos está la próxima repisa? Dependencia es.

API-Bestia
fuente
44
La dependencia circular +1 no es realmente un problema aquí. En esta etapa no hay razón para preocuparse por eso. Si el juego crece y el código madura, probablemente sea una buena idea refactorizar esas clases de Jugador y Mundo en subclases, tener un sistema basado en componentes adecuado, clases para el manejo de entradas, tal vez un Renderizado, etc. Pero para Un comienzo, no hay problema.
Laurent Couvidou
44
-1, definitivamente esa no es la única razón para no introducir dependencias circulares. Al no presentarlos, hace que su sistema sea más fácil de extender y cambiar.
jcora
44
@Bane No puedes codificar nada sin ese pegamento. La diferencia es la cantidad de indirección que agrega. Si tiene las clases Juego -> Mundo -> Entidad o si tiene las clases Juego -> Mundo, SoundManager, InputManager, PhysicsEngine, ComponentManager. Hace que las cosas sean menos legibles debido a toda la sobrecarga (sintáctica) y la complejidad implícita. Y en un momento necesitará los componentes para interactuar entre sí. Y ese es el punto donde una clase de pegamento hace las cosas más fáciles que todo dividido entre muchas clases.
API-Beast el
3
No, estás moviendo los postes de la portería. Por supuesto que algo debe llamar render(World). El debate gira en torno a si todo el código debe estar concentrado dentro de una clase, o si el código debe dividirse en unidades lógicas y funcionales, que luego son más fáciles de mantener, ampliar y administrar. Por cierto, buena suerte reutilizando esos gerentes de componentes, motores de física y gerentes de entrada, todos inteligentemente indiferenciados y completamente acoplados.
jcora
1
@Bane Hay otras formas de dividir las cosas en fragmentos lógicos que introducir nuevas clases, por cierto. También puede agregar nuevas funciones o dividir sus archivos en varias secciones separadas por bloques de comentarios. Simplemente mantenerlo simple no significa que el código será un desastre.
API-Beast
13

Su diseño actual parece ir en contra del primer principio del diseño SÓLIDO .

Este primer principio, llamado el "principio de responsabilidad única", generalmente es una buena guía a seguir para no crear objetos monolíticos de hacer todo que siempre dañarán su diseño.

Para concretar, su Worldobjeto es responsable tanto de actualizar y mantener el estado del juego como de dibujar todo.

¿Qué pasa si su código de renderizado cambia / tiene que cambiar? ¿Por qué debería tener que actualizar ambas clases que en realidad no tienen nada que ver con el renderizado? Como Liosan ya ha dicho, deberías tener un Renderer.


Ahora, para responder a su pregunta real ...

Hay muchas formas de hacer esto, y esta es solo una forma de desacoplar:

  1. El mundo no sabe qué es un jugador.
    • Sin embargo, tiene una lista de Objects en la que se encuentra el jugador, pero no depende de la clase de jugador (use la herencia para lograr esto).
  2. El jugador es actualizado por algunos InputManager.
  3. El mundo maneja la detección de movimiento y colisión, aplicando cambios físicos adecuados y enviando actualizaciones a los objetos.
    • Por ejemplo, si el objeto A y el objeto B chocan, el mundo les informará y luego podrían manejarlo por sí mismos.
    • El mundo aún manejaría la física (si su diseño es así).
    • Entonces, ambos objetos podrían ver si la colisión les interesa o no. Por ejemplo, si el objeto A era el jugador y el objeto B era una espiga, entonces el jugador podría aplicarse daño a sí mismo.
    • Sin embargo, esto se puede resolver de otras maneras.
  4. El Rendererdibuja todos los objetos.
jcora
fuente
Dices que el mundo no sabe qué es un jugador, pero maneja la detección de colisión que puede necesitar conocer las propiedades del jugador, si es uno de los objetos que colisionan.
Markus von Broady
Herencia, el mundo debe ser consciente de algún tipo de objetos, que pueden describirse de manera general. El problema no es que el mundo solo tenga una referencia al jugador, sino que pueda depender de él como clase (es decir, usar campos como los healthque solo tiene esta instancia Player).
jcora
Ah, quiere decir que el mundo no tiene referencia al jugador, solo tiene una serie de objetos que implementan la interfaz ICollidable, junto con el jugador si es necesario.
Markus von Broady
2
+1 Buena respuesta. Pero: "ignore a todas las personas que dicen que un buen diseño de software no es importante". Común. Nadie dijo eso.
Laurent Couvidou
2
Editado!
Parecía un
1

El jugador debe preguntarle al mundo sobre cosas como la detección de colisiones. La forma de evitar la dependencia circular es no hacer que el mundo tenga una dependencia del jugador. El mundo necesita saber dónde está dibujando: probablemente desee abstraerlo más lejos, tal vez con una referencia a un objeto de cámara que a su vez puede contener una referencia a alguna entidad para rastrear.

Lo que desea evitar en términos de referencias circulares no es tanto mantener referencias entre sí, sino más bien referirse explícitamente en el código.

Tom Johnson
fuente
1

Siempre que dos tipos diferentes de objetos puedan preguntarse entre sí. Dependerán unos de otros, ya que necesitan mantener una referencia al otro para llamar a sus métodos.

Puede evitar la dependencia circular haciendo que el Mundo le pregunte al Jugador, pero el Jugador no puede preguntarle al Mundo, o viceversa. De esta manera, el mundo tiene referencias a los jugadores, pero los jugadores no necesitan referencias al mundo. O viceversa. Pero esto no resolverá el problema, porque el mundo necesitaría preguntar a los jugadores si tienen algo que preguntar, y decirles en la próxima llamada ...

Por lo tanto, no puede evitar este "problema" y creo que no hay necesidad de preocuparse por eso. Mantenga el diseño estúpido simple todo el tiempo que pueda.

Calmarius
fuente
0

Excluyendo los detalles sobre el jugador y el mundo, tienes un caso simple de no querer introducir una dependencia circular entre dos objetos (que dependiendo de tu idioma puede no importar, mira el enlace en el comentario de Fuhrmanator). Existen al menos dos soluciones estructurales muy simples que se aplicarían a este y otros problemas similares:

1) Introduce el patrón singleton en tu clase mundial . Esto permitirá que el jugador (y cualquier otro objeto) encuentre fácilmente el objeto mundial sin búsquedas costosas o enlaces permanentes. La esencia de este patrón es que la clase tiene una referencia estática a la única instancia de esa clase, que se establece en la instanciación del objeto y se borra en su eliminación.

Dependiendo de su lenguaje de desarrollo y la complejidad que desee, podría implementarlo fácilmente como una superclase o interfaz y reutilizarlo para muchas clases principales de las que no espera tener más de una en su proyecto.

2) Si el lenguaje en el que se está desarrollando lo admite (muchos lo hacen), use una referencia débil . Esta es una referencia que no afecta cosas como la recolección de basura. Es útil en estos casos exactamente, solo asegúrese de no hacer suposiciones sobre si el objeto al que hace referencia débil todavía existe.

En su caso particular, su (s) Jugador (s) podrían tener una referencia débil al mundo. El beneficio de esto (como con el singleton) es que no necesita buscar el objeto mundial de alguna manera en cada cuadro, o tener una referencia permanente que obstaculice los procesos afectados por referencias circulares como la recolección de basura.

FlintZA
fuente
0

Como los otros han dicho, creo que su Worldestá haciendo una cosa demasiados: se trata de tanto contener el juego Map(que debe ser una entidad distinta) y sea una Renderera la vez.

Por lo tanto, cree un nuevo objeto (llamado GameMap, posiblemente) y almacene los datos de nivel de mapa en él. Escriba funciones en él que interactúen con el mapa actual.

Entonces también necesitas un Rendererobjeto. Usted podría hacer que este Rendererobjeto lo que tanto contiene GameMap y Player(así como Enemies), y también los atrae.

bobobobo
fuente
-6

Puede evitar dependencias circulares al no agregar las variables como miembros. Use una función estática CurrentWorld () para el jugador o algo así. Sin embargo, no invente una interfaz diferente de la implementada en World, esto es completamente innecesario.

También es posible destruir la referencia antes / mientras se destruye el objeto jugador para detener efectivamente los problemas causados ​​por referencias circulares.

serpiente5
fuente
1
Estoy contigo. OOP está demasiado sobrevalorado. Los tutoriales y la educación saltan rápidamente a OO después de aprender las cosas básicas del flujo de control. Los programas OO son generalmente más lentos que el código de procedimiento, ya que existe una burocracia entre sus objetos, tiene muchos accesos de puntero, lo que provoca una carga de errores de caché. Tu juego funciona pero muy lento. Los juegos reales, muy rápidos y ricos en funciones, que utilizan matrices globales simples y funciones optimizadas a mano y ajustadas para todo para evitar errores de caché. Lo que puede resultar en un aumento de diez veces en el rendimiento.
Calmarius