Pregunta de arquitectura / diseño del juego: crear un motor eficiente y evitar instancias globales (juego C ++)

28

Tenía una pregunta sobre la arquitectura del juego: ¿Cuál es la mejor manera de que diferentes componentes se comuniquen entre sí?

Realmente me disculpo si esta pregunta ya se ha hecho un millón de veces, pero no puedo encontrar nada con exactamente el tipo de información que estoy buscando.

He estado tratando de construir un juego desde cero (C ++ si es importante) y he observado algún software de juegos de código abierto en busca de inspiración (Super Maryo Chronicles, OpenTTD y otros). Noté que muchos de estos diseños de juegos usan instancias globales y / o singletons en todo el lugar (para cosas como colas de renderizado, gestores de entidades, gestores de video, etc.). Intento evitar instancias globales y singletons y construir un motor que esté lo más acoplado posible, pero me encuentro con algunos obstáculos que se deben a mi inexperiencia en el diseño efectivo. (Parte de la motivación para este proyecto es abordar esto :))

He creado un diseño donde tengo un GameCoreobjeto principal que tiene miembros que son análogos a las instancias globales que veo en otros proyectos (es decir, tiene un administrador de entrada, un administrador de video, un GameStageobjeto que controla todas las entidades y el juego) para cualquier etapa que esté cargada actualmente, etc.). El problema es que, dado que todo está centralizado en el GameCoreobjeto, no tengo una manera fácil para que los diferentes componentes se comuniquen entre sí.

Mirando Super Maryo Chronicles, por ejemplo, cada vez que un componente del juego necesita comunicarse con otro componente (es decir, un objeto enemigo quiere agregarse a la cola de renderizado para ser dibujado en la etapa de renderizado), solo habla con el instancia global

Para mí, tengo que hacer que mis objetos del juego pasen información relevante al GameCoreobjeto, para que el GameCoreobjeto pueda pasar esa información a los otros componentes del sistema que la necesita (es decir, para la situación anterior, cada objeto enemigo pasaría su información de renderizado al GameStageobjeto, que lo recolectaría todo y se lo devolvería GameCore, lo que a su vez lo pasaría al administrador de video para su renderizado). Parece un diseño realmente horrible, y estaba tratando de pensar en una resolución para esto. Mis pensamientos sobre posibles diseños:

  1. Instancias globales (diseño de Super Maryo Chronicles, OpenTTD, etc.)
  2. Hacer que el GameCoreobjeto actúe como un intermediario a través del cual se comunican todos los objetos (diseño actual descrito anteriormente)
  3. Proporcione punteros de componentes a todos los demás componentes con los que necesitarán hablar (es decir, en el ejemplo de Maryo anterior, la clase enemiga tendría un puntero al objeto de video con el que necesita hablar)
  4. Divida el juego en subsistemas: por ejemplo, tenga objetos de administrador en el GameCoreobjeto que manejen la comunicación entre los objetos en su subsistema
  5. (¿Otras opciones? ....)

Me imagino que la opción 4 anterior es la mejor solución, pero tengo algunos problemas para diseñarla ... tal vez porque he estado pensando en términos de los diseños que he visto que usan globales. Parece que estoy tomando el mismo problema que existe en mi diseño actual y lo estoy replicando en cada subsistema, solo que a una escala menor. Por ejemplo, el GameStageobjeto descrito anteriormente es un intento de esto, pero el GameCoreobjeto todavía está involucrado en el proceso.

¿Alguien puede ofrecer algún consejo de diseño aquí?

¡Gracias!

Awesomania
fuente
1
Entiendo tu instinto de que los singletons no son un gran diseño. En mi experiencia, han sido la forma más sencilla de administrar la comunicación entre sistemas
Emmett Butler
44
Agregar como comentario ya que no sé si es una mejor práctica. Tengo un GameManager central que se compone de subsistemas como InputSystem, GraphicsSystem, etc. Cada subsistema toma el GameManager como parámetro en el constructor y almacena la referencia a un miembro privado de la clase. En ese momento, puedo referirme a cualquier otro sistema accediendo a él a través de la referencia de GameManager.
Inisheer
Cambié las etiquetas porque esta pregunta es sobre el código, no sobre el diseño del juego.
Klaim 01 de
Este hilo es un poco viejo, pero tengo exactamente el mismo problema. Yo uso OGRE y trato de usar la mejor manera, en mi opinión, la opción # 4 es el mejor enfoque. He compilado algo como el Advanced Ogre Framework, pero esto no es muy modular. Creo que necesito un manejo de entrada del subsistema que solo obtenga los golpes del teclado y los movimientos del mouse. Lo que no entiendo es, ¿cómo puedo crear tal administrador de "comunicación" entre los subsistemas?
Dominik2000
1
Hola @ Dominik2000, este es un sitio de preguntas y respuestas, no un foro. Si tiene una pregunta, debe publicar una pregunta real y no una respuesta a una existente. Vea las preguntas frecuentes para más detalles.
Josh

Respuestas:

19

Algo que usamos en nuestros juegos para organizar nuestros datos globales es el patrón de diseño de ServiceLocator . La ventaja de este patrón en comparación con el patrón Singleton es que la implementación de sus datos globales puede cambiar durante el tiempo de ejecución de la aplicación. Además, sus objetos globales también se pueden cambiar durante el tiempo de ejecución. Otra ventaja es que es más fácil administrar el orden de inicialización de sus objetos globales, lo cual es muy importante especialmente en C ++.

por ejemplo (código C # que se puede traducir fácilmente a C ++ o Java)

Digamos que tiene una interfaz de back-end de representación que tiene algunas operaciones comunes para representar cosas.

public interface IRenderBackend
{
    void Draw();
}

Y que tiene la implementación de back-end de renderizado predeterminada

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

En algunos diseños parece legítimo poder acceder al backend de renderizado globalmente. En el patrón Singleton , eso significa que cada implementación de IRenderBackend debe implementarse como una instancia global única. Pero usar el patrón ServiceLocator no requiere esto.

Así es cómo:

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

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

Para poder acceder a su objeto global, primero debe inicializarlo.

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

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

Solo para demostrar cómo las implementaciones pueden variar durante el tiempo de ejecución, digamos que su juego tiene un minijuego donde el renderizado es isométrico e implementa un IsometricRenderBackend .

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

Cuando realiza la transición del estado actual al estado del minijuego, solo necesita cambiar el backend de representación global proporcionado por el localizador de servicios.

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

Otra ventaja es que también puede usar servicios nulos. Por ejemplo, si tuviéramos un ISoundManager servicio y el usuario desea desactivar el sonido, que pudimos implementar un NullSoundManager que no hace nada cuando se llama a sus métodos, por lo que mediante el establecimiento de la ServiceLocator 's objeto de servicio a un NullSoundManager objeto que podríamos lograr este resultado con casi ninguna cantidad de trabajo.

Para resumir, a veces puede ser imposible eliminar datos globales, pero eso no significa que no pueda organizarlos correctamente y de una manera orientada a objetos.

vdaras
fuente
He investigado esto antes, pero en realidad no lo he implementado en ninguno de mis diseños. Esta vez, planeo hacerlo. Gracias :)
Awesomania
3
@Erevis Entonces, básicamente, estás describiendo una referencia global al objeto polimórfico. A su vez, esto es solo una doble indirección (puntero -> interfaz -> implementación). En C ++ se puede implementar fácilmente como std::unique_ptr<ISomeService>.
Shadows In Rain
1
Puede cambiar la estrategia de inicialización para "inicializar en el primer acceso" y evitar la necesidad de tener una secuencia de código externa que asigne y envíe servicios al localizador. Puede agregar una lista de "depende de" a los servicios para que, cuando se inicialice, se configuren automáticamente otros servicios que necesita y no rezar para que alguien se acordó de hacerlo en main.cpp. Una buena respuesta con flexibilidad para futuros ajustes.
Patrick Hughes
4

Hay muchas formas de diseñar un motor de juego y todo se reduce a preferencias.

Para sacar lo básico del camino, algunos desarrolladores prefieren diseñarlo como una pirámide en la que hay una clase principal superior a la que se suele denominar clase kernel, core o framework que crea, posee e inicializa una serie de subsistemas como como audio, gráficos, redes, física, IA y gestión de tareas, entidades y recursos. En general, estos subsistemas están expuestos a usted por esta clase de marco y generalmente pasaría esta clase de marco a sus propias clases como argumento de constructor cuando sea apropiado.

Creo que estás en el camino correcto con tu pensamiento de la opción # 4.

Tenga en cuenta cuando se trata de la comunicación en sí misma, que no siempre tiene que implicar una función directa llamada a sí misma. Hay muchas formas indirectas en que puede ocurrir la comunicación, ya sea a través de algún método indirecto usando Signal and Slotso usando Messages.

A veces, en los juegos, es importante permitir que las acciones ocurran de forma asincrónica para mantener nuestro bucle de juego en movimiento lo más rápido posible para que las velocidades de cuadros sean fluidas a simple vista. A los jugadores no les gustan las escenas lentas y entrecortadas, por lo que tenemos que encontrar formas de mantener las cosas fluyendo para ellos, pero mantener la lógica fluyendo pero también bajo control y ordenada. Si bien las operaciones asincrónicas tienen su lugar, tampoco son la respuesta para cada operación.

Solo sepa que tendrá una combinación de comunicaciones sincrónicas y asincrónicas. Elija lo que sea apropiado, pero sepa que necesitará admitir ambos estilos entre sus subsistemas. Diseñar soporte para ambos le servirá en el futuro.

Naros
fuente
1

Solo debe asegurarse de que no haya dependencias inversas o cíclicas. Por ejemplo, si tiene una clase Core, y esta Coretiene un Level, y Leveltiene una lista de Entity, entonces el árbol de dependencia debería verse así:

Core --> Level --> Entity

Entonces, dado este árbol de dependencia inicial, nunca debería Entitydepender de Levelo Core, y Levelnunca debería depender de Core. Si necesita Levelo Entitytiene acceso a datos que están más arriba en el árbol de dependencias, debe pasarse como parámetro por referencia.

Considere el siguiente 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, puede ver que cada uno Entitytiene acceso al Levely el que Leveltiene acceso al Core. Observe que cada uno Entityalmacena una referencia al mismo Level, desperdiciando memoria. Al darse cuenta de esto, debe preguntarse si cada uno Entityrealmente necesita acceso al Level.

En mi experiencia, hay A) Una solución realmente obvia para evitar dependencias inversas, o B) No hay forma posible de evitar instancias globales y singletons.

Las manzanas
fuente
¿Me estoy perdiendo de algo? Usted menciona 'nunca debería tener una entidad que dependa del nivel' pero luego describe su ctor como 'Entidad (Level & levelIn)'. Entiendo que la dependencia se pasa por ref pero sigue siendo una dependencia.
Adam Naylor
@AdamNaylor El punto es que a veces realmente necesitas dependencias inversas, y puedes evitar los globales pasando referencias. Sin embargo, en general, es mejor evitar estas dependencias por completo, y no siempre está claro cómo hacerlo.
Manzanas
0

Entonces, básicamente, ¿quieres evitar el estado mutable global ? Puede hacerlo local, inmutable o no ser un estado en absoluto. Esta última es más eficiente y flexible, en mi opinión. Se conoce como ocultación de la multiplicación.

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

extern ISomeComponent & g_SomeComponent; // will be defined somewhere else;
Sombras en la lluvia
fuente
0

La pregunta en realidad parece ser acerca de cómo reducir el acoplamiento sin sacrificar el rendimiento. Todos los objetos globales (servicios) generalmente forman una especie de contexto que es mutable durante el tiempo de ejecución del juego. En este sentido, el patrón del localizador de servicios dispersa diferentes partes del contexto en diferentes partes de la aplicación, lo que puede o no ser lo que desea. Otro enfoque del mundo real sería declarar una estructura como esta:

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

Y páselo como un puntero bruto no propietario sEnvironment*. Aquí los punteros apuntan a las interfaces, por lo que el acoplamiento se reduce de manera similar en comparación con el localizador de servicios. Sin embargo, todos los servicios están en un solo lugar (lo que podría o no ser bueno). Este es solo otro enfoque.

Sergey K.
fuente