Estado del juego 'Pila'?

52

Estaba pensando en cómo implementar estados de juego en mi juego. Las cosas principales que quiero para ello son:

  • Estados superiores semitransparentes: poder ver a través de un menú de pausa el juego detrás

  • Algo OO-Me parece más fácil de usar y entender la teoría detrás, así como mantenerlo organizado y agregarle más.



Estaba planeando usar una lista vinculada y tratarla como una pila. Esto significa que podría acceder al siguiente estado para la semi-transparencia.
Plan: haga que la pila de estados sea una lista vinculada de punteros a IGameStates. El estado superior maneja sus propios comandos de actualización y entrada, y luego tiene un miembro isTransparent para decidir si se debe dibujar el estado debajo.
Entonces podría hacer:

states.push_back(new MainMenuState());
states.push_back(new OptionsMenuState());
states.pop_front();

Para representar la carga del jugador, luego vaya a las opciones y luego al menú principal.
¿Es una buena idea, o ...? ¿Debo mirar algo más?

Gracias.

El pato comunista
fuente
¿Desea ver MainMenuState detrás de OptionsMenuState? ¿O solo la pantalla del juego detrás del OptionsMenuState?
Skizz
El plan era que los estados tendrían un valor / indicador de opacidad / isTransparent. Verificaría y vería si el estado superior tenía este verdadero y, de ser así, qué valor tenía. Luego renderízalo con tanta opacidad sobre el otro estado. En este caso, no, no lo haría.
El pato comunista
Sé que es tarde en el día, pero para futuros lectores: no lo use newde la manera que se muestra en el código de muestra, solo está pidiendo pérdidas de memoria u otros errores más graves.
Pharap

Respuestas:

44

Trabajé en el mismo motor que coderanger. Tengo un punto de vista diferente. :)

Primero, no teníamos una pila de FSM, teníamos una pila de estados. Una pila de estados forma un solo FSM. No sé cómo se vería una pila de FSM. Probablemente demasiado complicado para hacer algo práctico.

Mi mayor problema con nuestra máquina de estado global era que era un conjunto de estados, y no un conjunto de estados. Esto significa, por ejemplo, ... / MainMenu / Loading era diferente de ... / Loading / MainMenu, dependiendo de si aparecía el menú principal antes o después de la pantalla de carga (el juego es asíncrono y la carga es principalmente impulsada por el servidor )

Como dos ejemplos de cosas esto hizo feo:

  • Condujo, por ejemplo, al estado LoadingGameplay, por lo que tenía Base / Loading, y Base / Gameplay / LoadingGameplay para cargar dentro del estado Gameplay, que tenía que repetir gran parte del código en el estado de carga normal (pero no todos, y agregar algo más) )
  • Tuvimos varias funciones como "si en el creador de personajes, vaya al juego; si está en el juego, vaya a la selección de personaje; si está en la selección de personaje, vuelva a iniciar sesión", porque queríamos mostrar las mismas ventanas de interfaz en diferentes estados, pero retroceder / avanzar Los botones todavía funcionan.

A pesar del nombre, no era muy "global". La mayoría de los sistemas de juego internos no lo usaban para rastrear sus estados internos, porque no querían que sus estados se burlaran de otros sistemas. Otros, por ejemplo, el sistema UI, podrían usarlo pero solo para copiar el estado en sus propios sistemas estatales locales. (Especialmente advertiría contra el sistema para los estados de la interfaz de usuario. El estado de la interfaz de usuario no es una pila, es realmente un DAG, y tratar de forzar cualquier otra estructura en él solo hará que las UI sean frustrantes de usar).

Lo que fue bueno fue aislar las tareas para integrar el código de los programadores de infraestructura que no sabían cómo se estructuraba realmente el flujo del juego, para que pudieras decirle al tipo que escribe el parche "pon tu código en Client_Patch_Update", y al tipo que escribe los gráficos cargando "ponga su código en Client_MapTransfer_OnEnter", y podríamos intercambiar ciertos flujos lógicos sin muchos problemas.

En un proyecto paralelo, he tenido mejor suerte con un conjunto de estados en lugar de una pila , sin tener miedo de hacer varias máquinas para sistemas no relacionados, y me niego a caer en la trampa de tener un "estado global", que es realmente solo una forma complicada de sincronizar las cosas a través de variables globales: seguro, terminarás haciéndolo cerca de una fecha límite, pero no diseñes con eso como tu objetivo . Básicamente, el estado en un juego no es una pila, y los estados en un juego no están todos relacionados.

El GSM también, como tienden a hacer los punteros de función y el comportamiento no local, hizo que la depuración sea más difícil, aunque depurar ese tipo de grandes transiciones de estado tampoco fue muy divertido antes de que lo tuviéramos. Los conjuntos de estados en lugar de las pilas de estados realmente no ayudan a esto, pero debes ser consciente de ello. Las funciones virtuales en lugar de los punteros de función pueden aliviar eso de alguna manera.


fuente
Gran respuesta, gracias! Creo que puedo aprovechar mucho tu publicación y tus experiencias pasadas. : D + 1 / marca.
El pato comunista
Lo bueno de una jerarquía es que puede crear estados de utilidad que se colocan en la parte superior y no tiene que preocuparse por qué más se está ejecutando.
coderanger
No veo cómo ese es un argumento para una jerarquía en lugar de conjuntos. Más bien, una jerarquía hace que toda comunicación entre estados sea más complicada, porque no tienes idea de dónde fueron empujados.
El punto de que las IU son en realidad DAG está bien tomado, pero no estoy de acuerdo en que ciertamente pueda representarse en una pila. Cualquier gráfico acíclico dirigido conectado (y no puedo pensar en un caso en el que no sea un DAG conectado) se puede mostrar como un árbol, y una pila es esencialmente un árbol.
Ed Ropple
2
Las pilas son un subconjunto de árboles, que son un subconjunto de DAG, que son un subconjunto de todos los gráficos. Todas las pilas son árboles, todos los árboles son DAG, pero la mayoría de los DAG no son árboles, y la mayoría de los árboles no son pilas. Los DAG tienen un orden topológico que le permitirá almacenarlos en una pila (para atravesar, por ejemplo, resolución de dependencia), pero una vez que los apiña en la pila, ha perdido información valiosa. En este caso, la capacidad de navegar entre una pantalla y su padre si tiene un hermano anterior.
11

Aquí hay un ejemplo de implementación de una pila de gamestate que encontré muy útil: http://creators.xna.com/en-US/samples/gamestatemanagement

Está escrito en C # y para compilarlo necesita el marco XNA, sin embargo, puede consultar el código, la documentación y el video para obtener la idea.

Puede admitir transiciones de estado, estados transparentes (como cuadros de mensajes modales) y estados de carga (que gestionan la descarga de estados existentes y la carga del estado siguiente).

Ahora uso los mismos conceptos en mis proyectos de pasatiempos (no C #) (concedido, puede que no sea adecuado para proyectos más grandes) y para proyectos pequeños / pasatiempos definitivamente puedo recomendar el enfoque.

Janis Kirsteins
fuente
5

Esto es similar a lo que usamos, una pila de FSM. Básicamente, solo asigne a cada estado una función de entrada, salida y marca y llámelos en orden. Funciona muy bien para manejar cosas como cargar también.

coderanger
fuente
3

Uno de los volúmenes de "Gemas de programación de juegos" tenía una implementación de máquina de estados destinada a estados de juegos; http://emergent.net/Global/Documents/textbook/Chapter1_GameAppFramework.pdf tiene un ejemplo de cómo usarlo para un juego pequeño, y no debe ser demasiado específico de Gamebryo para ser legible.

Tom Hudson
fuente
La primera sección de "Programación de juegos de rol con DirectX" también implementa un sistema de estado (y un sistema de proceso, distinción muy interesante).
Ricket
Ese es un gran documento y explica casi exactamente cómo lo he implementado en el pasado, a excepción de la innecesaria jerarquía de objetos que usan en los ejemplos.
dash-tom-bang
3

Solo para agregar un poco de estandarización a la discusión, el término clásico de CS para este tipo de estructuras de datos es un autómata pushdown .

munificente
fuente
No estoy seguro de que cualquier implementación en el mundo real de las pilas de estado sea casi equivalente a un autómata pushdown. Como se menciona en otras respuestas, las implementaciones prácticas terminan invariablemente con comandos como "abrir dos estados", "intercambiar estos estados" o "pasar estos datos al siguiente estado fuera de la pila". Y un autómata es un autómata, una computadora, no una estructura de datos. Tanto las pilas de estado como los autómatas pushdown usan una pila como estructura de datos.
1
"No estoy seguro de que cualquier implementación en el mundo real de las pilas de estado sea casi equivalente a un autómata pushdown". ¿Cual es la diferencia? Ambos tienen un conjunto finito de estados, una historia de estados y operaciones primitivas para impulsar y reventar estados. Ninguna de las otras operaciones que menciona son diferentes desde el punto de vista financiero. "Pop dos estados" solo aparece dos veces. "intercambio" es un pop y un empujón. Pasar datos está fuera de la idea central, pero cada juego que usa un "FSM" también agrega datos adicionales sin sentir que el nombre ya no se aplica.
munificent
En un autómata pushdown, el único estado que puede afectar su transición es el estado en la parte superior. No se permite intercambiar dos estados en el medio; incluso mirar los estados en el medio no está permitido. Siento que la expansión semántica del término "FSM" es razonable y tiene beneficios (y todavía tenemos los términos "DFA" y "NFA" para el significado más restringido), pero "autómata pushdown" es estrictamente un término informático y solo hay confusión esperando si lo aplicamos a todos los sistemas basados ​​en pila.
Prefiero aquellas implementaciones donde el único estado que puede afectar algo es el estado que está arriba, aunque en algunos casos es útil poder filtrar la entrada de estado y pasar el procesamiento a un estado "inferior". (Por ejemplo, el procesamiento de entrada del controlador se asigna a este método, el estado superior toma los bits que le importan y posiblemente los borra y luego pasa el control al siguiente estado en la pila.)
dash-tom-bang
1
Buen punto, arreglado!
munificente
1

No estoy seguro de que una pila sea completamente necesaria, además de limitar la funcionalidad del sistema de estado. Con una pila, no puede 'salir' de un estado a una de varias posibilidades. Digamos que comienza en "Menú principal" y luego va a "Cargar juego", puede que desee pasar al estado "Pausa" después de cargar con éxito el juego guardado y volver al "Menú principal" si el usuario cancela la carga.

Solo me gustaría que el estado especifique el estado a seguir cuando salga.

Para aquellos casos en los que desea volver al estado anterior al estado actual, por ejemplo "Menú principal-> Opciones-> Menú principal" y "Pausa-> Opciones-> Pausa", simplemente pase como parámetro de inicio al estado el Estado para volver a.

Skizz
fuente
Tal vez entendí mal la pregunta?
Skizz
No, no lo hiciste. Creo que el votante decepcionado lo hizo.
El pato comunista el
El uso de una pila no impide el uso de transiciones de estado explícitas.
dash-tom-bang
1

Otra solución para las transiciones y otras cosas similares es proporcionar el destino y el estado de origen, junto con la máquina de estado, que podría estar vinculada al "motor", sea lo que sea. La verdad es que la mayoría de las máquinas de estado probablemente tendrán que adaptarse al proyecto en cuestión. Una solución puede beneficiar a este o aquel juego, otras soluciones pueden obstaculizarlo.

class StateMachine
{
public:
    StateMachine(Engine *);
    void Push(State *);
    State *Pop();
    void Update();
    Engine *GetEngine();

private:
    std::stack<State *> _states;
    Engine *_engine;
};

Los estados se envían con el estado actual y la máquina como parámetros.

void StateMachine::Push(State *state)
{
    State *from = 0;
    if (!_states.empty()) from = _states.top();
    _states.push(state);
    state->Enter(this, from);
}

Los estados aparecen de la misma manera. Si llama Enter()al inferior Statees una pregunta de implementación.

State *StateMachine::Pop()
{
    _ASSERT(!_states.empty());
    State *state = _states.top();
    State *to = 0;
    _states.pop();
    if (!_states.empty()) to = _states.top();
    state->Exit(this, to);
    return state;
}

Al ingresar, actualizar o salir, Stateobtiene toda la información que necesita.

void SomeGameState::Enter(StateMachine *sm, State *from)
{
    Engine *eng = sm->GetEngine();
    eng->GetKeyboard()->KeyDown.Bind(this, &SomeGameState::KeyDown);
    LoadLevelState *state = new LoadLevelState();
    state->SetLevel(eng->GetSaveGame()->GetLevelName());
    state->Load.Bind(this, &SomeGameState::OnLevelLoaded);
    sm->Push(state);
}

void SomeGameState::Update(StateMachine *sm)
{
    Engine *eng = sm->GetEngine();
    float time = eng->GetFrameTime();
    if (shouldExit)
        sm->Pop();
}

void SomeGameState::Exit(StateMachine *sm, State *from)
{
    Engine *eng = sm->GetEngine();
    eng->GetKeyboard()->KeyDown.UnsubscribeAll(this);
}
Nick Bedford
fuente
0

Utilicé un sistema muy similar en varios juegos y descubrí que, con un par de excepciones, sirve como un excelente modelo de interfaz de usuario.

Los únicos problemas que encontramos fueron casos en los que en ciertos casos se desea volver a mostrar múltiples estados antes de presionar un nuevo estado (volvimos a fluir la interfaz de usuario para eliminar el requisito, ya que generalmente era un signo de una interfaz de usuario incorrecta) y crear un estilo de asistente flujos lineales (se resuelven fácilmente pasando los datos al siguiente estado).

La implementación que utilizamos en realidad envolvió la pila y manejó la lógica para actualizar y renderizar, así como las operaciones en la pila. Cada operación en la pila desencadenó eventos en los estados para notificarles sobre la operación que está ocurriendo.

También se agregaron algunas funciones auxiliares para simplificar tareas comunes, como Swap (Pop & Push, para flujos lineales) y Reset (para volver al menú principal o finalizar un flujo).

Jason Kozak
fuente
Como modelo de UI, esto tiene sentido. Dudaría en llamarlos estados, ya que en mi cabeza asociaría eso con los elementos internos del motor principal del juego, mientras que "Menú principal", "Menú de opciones", "Pantalla de juego" y "Pantalla de pausa" son de nivel superior, y a menudo no tienen interacción con el estado interno del juego principal, y simplemente envían comandos al motor central de la forma "Pausa", "Sin pausa", "Nivel de carga 1", "Nivel de inicio", "Nivel de reinicio", "Guardar" y "Restaurar", "establecer el nivel de volumen 57", etc. Obviamente, esto podría variar significativamente según el juego.
Kevin Cathcart
0

Este es el enfoque que adopto para casi todos mis proyectos, porque funciona increíblemente bien y es extremadamente simple.

Mi proyecto más reciente, Sharplike , maneja el flujo de control de esta manera exacta. Todos nuestros estados están conectados con un conjunto de funciones de eventos que se llaman cuando los estados cambian, y presenta un concepto de "pila con nombre" en el que puede tener múltiples pilas de estados dentro de la misma máquina de estados y ramificar entre ellos, un concepto herramienta, y no necesaria, pero útil para tener.

Advierto contra el paradigma "dígale al controlador qué estado debe seguir este cuando termine" sugerido por Skizz: no es estructuralmente sólido, y hace cosas como cuadros de diálogo (que en el paradigma estándar de estado de pila solo implica crear un nuevo subclase de estado con nuevos miembros, luego leerlo cuando regrese al estado de invocación) mucho más difícil de lo que tiene que ser.

Ed Ropple
fuente
0

Utilicé básicamente este sistema exacto en varios sistemas ortogonalmente; los estados frontend y del menú del juego (también conocido como "pausa"), por ejemplo, tenían sus propias pilas de estados. La interfaz de usuario del juego también usó algo como esto, aunque tenía aspectos "globales" (como la barra de salud y el mapa / radar) que el cambio de estado podría teñir pero que se actualizaba de manera común en todos los estados.

El menú del juego puede estar "mejor" representado por un DAG, pero con una máquina de estado implícita (cada opción de menú que va a otra pantalla sabe cómo ir allí, y al presionar el botón Atrás siempre aparece el estado superior) el efecto fue exactamente lo mismo.

Algunos de estos otros sistemas también tenían la funcionalidad de "reemplazar el estado superior", pero eso se implementaba normalmente de la StatePop()siguiente manera StatePush(x);.

El manejo de la tarjeta de memoria fue similar ya que en realidad empujé un montón de "operaciones" en la cola de operaciones (que funcionalmente hacía lo mismo que la pila, igual que FIFO en lugar de LIFO); una vez que comienza a usar este tipo de estructura ("hay una cosa que está sucediendo ahora, y cuando se hace, aparece") comienza a infectar cada área del código. Incluso la IA comenzó a usar algo como esto; la IA fue "despistada" y luego cambió a "cautelosa" cuando el jugador hizo ruidos pero no fue visto, y finalmente se elevó a "activa" cuando vio al jugador (y a diferencia de los juegos menores de la época, no se podía ocultar en una caja de cartón y haz que el enemigo se olvide de ti! No es que esté amargado ...).

GameState.h:

enum GameState
{
   k_frontend,
   k_gameplay,
   k_inGameMenu,
   k_moviePlayback,
   k_numStates
};

void GameStatePush(GameState);
void GameStatePop();
void GameStateUpdate();

GameState.cpp:

// k_maxNumStates could be bigger, but we don't need more than
// one of each state on the stack.
static const int k_maxNumStates = k_numStates;
static GameState s_states[k_maxNumStates] = { k_frontEnd };
static int s_numStates = 1;

static void (*s_startupFunctions)()[] =
   { FrontEndStart, GameplayStart, InGameMenuStart, MovieStart };
static void (*s_shutdownFunctions)()[] =
   { FrontEndStop, GameplayStop, InGameMenuStop, MovieStop };
static void (*s_updateFunctions)()[] =
   { FrontEndUpdate, GameplayUpdate, InGameMenuUpdate, MovieUpdate };

static void GameStateStart(GameState);
static void GameStateStop(GameState);

void GameStatePush(GameState gs)
{
   Assert(s_numStates < k_maxNumStates);
   GameStateStop(s_states[s_numStates - 1])
   s_states[s_numStates] = gs;
   s_numStates++;
   GameStateStart(gs);
}

void GameStatePop()
{
   Assert(s_numStates > 1);  // can't pop last state
   s_numStates--;
   GameStateStop(s_states[s_numStates]);
   GameStateStart(s_states[s_numStates - 1]);
}

void GameStateUpdate()
{
   GameState current = s_states[s_numStates - 1];
   s_updateFunctions[current]();
}

void GameStateStart(GameState gs)
{
   s_startupFunctions[gs]();
}

void GameStateStop(GameState gs)
{
   s_shutdownFunctions[gs]();
}
dash-tom-bang
fuente