¿Cuál sería la mejor manera de almacenar movimientos en un juego para permitir una reversión?

8

Estoy desarrollando un juego de mesa que tiene una clase de juego que controla el flujo del juego y jugadores vinculados a la clase de juego. El tablero es solo una clase visual, pero el control de los movimientos es todo por el juego.
El jugador puede intentar hacer movimientos falsos que conduzcan al juego diciéndole al jugador que este tipo de movimiento no está permitido (y decir la razón detrás de esto). Pero en algunos casos, el jugador puede hacer un movimiento y darse cuenta de que no es el mejor movimiento, o simplemente hacer clic en el tablero o simplemente intentar otro enfoque.
El juego se puede guardar y cargar, por lo que un enfoque podría ser almacenar todos los datos antes del movimiento y no permitir una reversión, pero permitir al usuario cargar el último turno guardado automáticamente. Esto parece un buen enfoque, pero involucra la interacción del usuario, y el rediseño del tablero podría ser tedioso para el usuario. ¿Hay una mejor manera de hacer este tipo de cosas o la arquitectura realmente importa en esto?

La clase de juego y la clase de jugador no son complicadas, por lo que es una buena idea clonar las clases, o separar los datos del juego de las reglas del juego para un mejor enfoque, o el guardado / carga (incluso automático en una reversión solicitada) está bien.

ACTUALIZACIÓN: cómo funciona este juego: tiene un tablero principal donde haces movimientos (movimientos simples) y un tablero de jugador que reacciona en los movimientos que hace en el tablero principal. También tiene movimientos de reacción de acuerdo con los movimientos de otros jugadores y sobre ti mismo. También puedes reaccionar cuando no sea tu turno, haciendo cosas en tu tablero. Tal vez no pueda deshacer cada movimiento, pero me gusta la idea de deshacer / rehacer flotando en una de las respuestas actuales.

gbianchi
fuente
44
Mire el patrón de recuerdo
1
Simplemente almacene el historial de todos los objetos en el tablero cada turno.
Ramhound
2
@Ramhound: Si lo haces de esa manera, tu programa podría convertirse fácilmente en un ... bueno ... RAM. ;)
Mason Wheeler
1
@MasonWheeler: no tiene que hacer un seguimiento de la historia más allá de cierto punto. Muchos programas almacenan toneladas de datos en una función similar y no tienen problemas con la memoria.
Ramhound
¿Puedes almacenar las coordenadas de movimiento en una pila? Esto se hace en juegos de ajedrez. YMMV dependiendo de si es un juego de mesa por turnos o un movimiento fluido como el pong.
mike30

Respuestas:

16

¿Por qué no almacenar el historial de todos los movimientos realizados (así como cualquier otro evento no determinista)? De esa manera siempre puedes reconstruir cualquier estado de juego dado.

Esto tomará significativamente menos espacio de almacenamiento que almacenar todos los estados del juego, y sería bastante simple de implementar.


fuente
2
Como beneficio adicional, en algún momento puede agregar una función de repetición similar a StarCraft.
Jonathan Rich
2
+1 Esto es similar a una técnica llamada Event Sourcing utilizada en otros contextos.
MattDavey
1
El comentario de @MattDavey Explica esta técnica de una manera excelente. Aceptaré esto porque puede ser bastante fácil de implementar, no importa cuán complejo sea el juego.
gbianchi
El abastecimiento de eventos es el camino a seguir: te lleva a cualquier punto del juego
Murph
8

Construir un sistema de deshacer es conceptualmente bastante simple. Solo necesita hacer un seguimiento de los cambios. Querrás una pila y un tipo de objeto que describa una parte del estado del juego. Su pila debe ser una pila de matrices / listas de objetos de estado del juego.

El truco es, no grabar movimientos que la gente hace . Veo esto en las otras respuestas, y se puede hacer, pero es mucho más complicado. En cambio, registre cómo se veía el tablero de juego antes de realizar el movimiento. Por ejemplo, cuando mueve una pieza, ha realizado dos cambios: la pieza dejó un cuadrado y la pieza se colocó en un nuevo cuadrado. Si crea una matriz que muestra cómo se veían esos dos cuadrados antes de que comenzara el movimiento, puede deshacer el movimiento simplemente restaurando esos dos cuadrados a la forma en que estaban antes del movimiento.

O, si su estado de juego se mantiene en las piezas y no en los cuadrados, lo que registra es la posición de la pieza antes de que se mueva. (Y si su pieza interactúa con otras piezas, como una captura en ajedrez, registre dónde estaban esas otras piezas antes de que se cambiaran).

Cuando ocurre cualquier movimiento:

  • Crear un nuevo marco de deshacer (matriz)
  • Cada vez que algo cambia, si aún no tiene un cambio para ese objeto en el cuadro Deshacer actual, agregue su estado al cuadro Deshacer actual antes de aplicar el cambio.
  • Cuando termine el movimiento, empuje el marco Deshacer sobre la pila.

Cuando el usuario dice Deshacer:

  • abre la parte superior de la pila y toma un cuadro Deshacer
  • iterar sobre cada objeto en el marco y restaurarlo
  • (Opcionalmente): haga un seguimiento de los cambios realizados aquí exactamente de la misma manera que lo hizo al configurar un cuadro Deshacer, y empuje el cuadro a una pila. Así es como implementa Redo. (Si hace esto, presionar un nuevo cuadro Deshacer también debería borrar la pila Rehacer).
Mason Wheeler
fuente
44
Esta es una buena idea, siempre que el estado del juego ocupe relativamente poca memoria. Mi primera contribución real de código abierto fue cambiar el sistema de deshacer de Cinelerra al modelo de "movimientos de registro", porque en proyectos grandes empujaría megabytes de datos de estado a la pila de deshacer cada vez que pulsaba una tecla. Puede que nunca sea un problema con un juego de mesa, pero si hay alguna posibilidad de que los requisitos de memoria aumenten así, es mejor morder la bala ahora.
Karl Bielefeldt
2
@KarlBielefeldt, veo esta respuesta como abogando por almacenar los cambios solo en el estado, no en todo el estado del juego en cada punto. La respuesta tiene muchas reflexiones útiles sobre el problema, pero no me gusta la apertura "no grabe movimientos como dicen las otras respuestas" cuando realmente termina abogando por algo muy similar.
@ dan1111: Estoy abogando por algo similar , pero sutilmente diferente. Cuando dices "grabar movimientos", eso suena como "grabar los cambios". Lo que estoy describiendo es "registrar cómo se veían las cosas antes de que se cambiaran", lo que hace que el sistema Deshacer sea mucho más limpio.
Mason Wheeler
1
@MasonWheeler, estoy de acuerdo en que la forma en que sugieres implementarlo es elegante. Sin embargo, para mí, registrar los estados de solo lo que cambió es simplemente una buena forma de registrar los movimientos. Supongo que en realidad es solo una discusión sobre la semántica ...
1
@kuhaku Nº grabación de los movimientos que se realizan está haciendo un registro de lo que se está cambiando a . Lo que estoy explicando aquí es que usted necesita para hacer un registro de lo que está cambiando a partir , de modo que pueda volver a ella.
Mason Wheeler
2

Una buena manera de implementar esto es encapsular sus movimientos en forma de objetos Command. Piense en una interfaz de comando que tiene los métodos move(Position newPosition)y undo. Una clase concreta puede implementar esta interfaz de tal manera que para el movemétodo, puede almacenar la posición actual en el tablero (la posición es una clase que contiene los valores de fila y columna probablemente), y luego hacer un movimiento a la fila y columna identificada por newPosition. Después de esto, el método agregará el comando ( this) a una pila global de objetos Command.

Ahora, cuando el usuario necesite retroceder al paso anterior, simplemente saque la última instancia de Command de la pila y llame a su undométodo. Esto también le brinda la capacidad de retroceder a todos los pasos que necesite.

Espero que ayude.

Samir Hasan
fuente
2
Command es un patrón de diseño clásico y exactamente lo que iba a sugerir. Además, creo que cada comando debe tener un puntero al jugador que hizo el movimiento para que pueda obtener una transcripción de todo el juego al final simplemente recorriendo la lista y llamando a una función de tipo "toString" para cada comando. Por ejemplo, "Annie agregó un cliente informal (trigo, calabaza, nabo) y un ayudante (Comprador). Bill entregado al cliente habitual (trigo, calabaza) por +8 en efectivo", serían las descripciones para un par de jugadores que se entregan "A las puertas de Loyang".
John Munsch
Buena sugerencia John.
Samir Hasan
1

Tocando hilos antiguos pero la forma más fácil que he encontrado para resolver este problema, al menos en escenarios complejos, es simplemente copiar cada maldita cosa antes de la operación del usuario ...

... suena un desperdicio, ¿verdad? Excepto si es realmente un desperdicio, entonces su próximo paso es hacer que la copia sea más barata para que las partes que no se cambiarán en el siguiente paso no se copien en profundidad.

En mi caso, a menudo trabajo con gigabytes de datos, pero el usuario a menudo solo toca megabytes para una operación, por lo que copiar todo normalmente sería extremadamente costoso y ridículamente derrochador ... pero he configurado estas estructuras de datos girando alrededor de poco profundas copiando lo que no cambia, y considero que esta es la solución más fácil y también abre muchas posibilidades nuevas, como estructuras de datos persistentes inmutables, redes nodales más seguras y múltiples hilos que ingresan algo y generan algo nuevo, edición no destructiva, creación de instancias objetos (poder copiar superficialmente, por ejemplo, sus costosos datos de malla pero tener el clon tiene su propia posición única en el mundo mientras apenas toma memoria), etc.

store all scene/application state in undo
perform user operation

on undo/redo:
   swap stored state with application state

Todos los proyectos desde entonces han seguido este patrón básico en lugar de tener que registrar cuidadosamente cada pequeño cambio de estado y para docenas de diferentes tipos de datos que no encajan en un modelo homogéneo (imagen / pintura de textura, cambios de propiedad, cambios de malla, cambios de peso, cambios jerárquicos de escena, cambios de sombreador que no estaban relacionados con la propiedad, como intercambiar uno con otro, cambios de envolvente, etc., etc.). En cambio, si eso es demasiado derrochador en términos de memoria y tiempo, entonces no se nos ocurre un sistema de deshacer más sofisticado, sino que buscamos optimizar la copia para que se puedan hacer copias parciales en todas las estructuras de datos más caras. Eso lo deja como un detalle de optimización en el que puede tener una implementación de deshacer que funcione correctamente de inmediato que siempre se mantendrá correcta, y siempre se mantendrá correcto es increíble cuando, en el pasado,

La otra forma es registrar los deltas (cambios) individualmente, y solía hacerlo en el pasado, pero para un software a gran escala muy complejo, a menudo era muy propenso a errores y tedioso, ya que era realmente fácil para un desarrollador de complementos para olvidar registrar algún cambio en la pila de deshacer cuando introdujeron nuevos conceptos en el sistema.

Ahora, una cosa, independientemente de si copia todo el estado del juego o registra deltas es evitar los punteros (suponiendo C o C ++) cuando sea posible o de lo contrario complicará mucho las cosas. Si usa índices, todo se vuelve más simple, ya que los índices no se invalidan con copias (ya sea del estado completo de la aplicación o de una parte del mismo). Como ejemplo con una estructura de datos de gráfico, si desea copiarlo en su totalidad o simplemente registrar deltas cuando los usuarios hacen nuevas conexiones o rompen conexiones en el gráfico, el estado querrá almacenar enlaces a los nodos antiguos y puede encontrarse con problemas de invalidación y cosas por el estilo. Es mucho más fácil si las conexiones usan índices relativos en una matriz, ya que es mucho más fácil evitar que esos índices sean invalidantes que los punteros.


fuente
1
Hilo antiguo ... problema de todos los días en un sistema de deshacer;).
gbianchi
0

Mantendría una lista de deshacer de los movimientos (válidos) realizados por el jugador.
Cada elemento de la lista debe contener suficiente información para restaurar el estado del juego a lo que era justo antes de que se realizara el movimiento. Dependiendo de la cantidad de estado de juego que haya y la cantidad de pasos para deshacer que desee admitir, esto podría ser una instantánea del estado del juego o información que le diga al motor del juego cómo revertir el movimiento.

Bart van Ingen Schenau
fuente