Diseño de un juego por turnos donde las acciones tienen efectos secundarios.

19

Estoy escribiendo una versión para computadora del juego Dominion . Es un juego de cartas por turnos donde las cartas de acción, las cartas del tesoro y las cartas de puntos de victoria se acumulan en el mazo personal de un jugador. Tengo la estructura de clase bastante bien desarrollada, y estoy empezando a diseñar la lógica del juego. Estoy usando python, y puedo agregar una GUI simple con pygame más tarde.

La secuencia de turnos de los jugadores se rige por una máquina de estados muy simple. Los giros pasan en sentido horario, y un jugador no puede salir del juego antes de que termine. El juego de un solo turno también es una máquina de estados; en general, los jugadores pasan por una "fase de acción", una "fase de compra" y una "fase de limpieza" (en ese orden). Basado en la respuesta a la pregunta ¿Cómo implementar un motor de juego por turnos? , la máquina de estados es una técnica estándar para esta situación.

Mi problema es que durante la fase de acción de un jugador, ella puede usar una carta de acción que tenga efectos secundarios, ya sea en sí misma o en uno o más de los otros jugadores. Por ejemplo, una carta de acción le permite a un jugador tomar un segundo turno inmediatamente después de la conclusión del turno actual. Otra carta de acción hace que todos los demás jugadores descarten dos cartas de sus manos. Sin embargo, otra carta de acción no hace nada para el turno actual, pero permite que un jugador robe cartas adicionales en su próximo turno. Para complicar aún más las cosas, con frecuencia hay nuevas expansiones en el juego que agregan nuevas cartas. Me parece que codificar los resultados de cada tarjeta de acción en la máquina de estado del juego sería feo e inadaptable. La respuesta al bucle de estrategia por turnos no entra en un nivel de detalle que aborde los diseños para resolver este problema.

¿Qué tipo de modelo de programación debo usar para abarcar el hecho de que el patrón general para tomar turnos puede modificarse mediante acciones que tienen lugar dentro del turno? ¿Debería el objeto del juego hacer un seguimiento de los efectos de cada carta de acción? O, si las tarjetas deben implementar sus propios efectos (por ejemplo, mediante la implementación de una interfaz), ¿qué configuración se requiere para darles suficiente potencia? He pensado algunas soluciones a este problema, pero me pregunto si hay una forma estándar de resolverlo. Específicamente, me gustaría saber qué objeto / clase / lo que sea responsable de realizar un seguimiento de las acciones que cada jugador debe hacer como consecuencia de una carta de acción que se está jugando, y también cómo se relaciona eso con los cambios temporales en la secuencia normal de La máquina de estado de giro.

Apis Utilis
fuente
2
Hola Apis Utilis, y bienvenidos a GDSE. Su pregunta está bien escrita y es genial que haya hecho referencia a las preguntas relacionadas. Sin embargo, su pregunta está cubriendo muchos problemas diferentes, y para cubrirla por completo, una pregunta probablemente debería ser enorme. Todavía puede obtener una buena respuesta, pero usted y el sitio se beneficiarán si analiza su problema un poco más. ¿Quizás comenzar con la construcción de un juego más simple y construir hasta Dominion?
michael.bartnett
1
Comenzaría por darle a cada carta un guión que modifique el estado del juego, y si no pasa nada extraño, recurra a las reglas de turno predeterminadas ...
Jari Komppa

Respuestas:

11

Estoy de acuerdo con Jari Komppa en que definir los efectos de la tarjeta con un potente lenguaje de script es el camino a seguir. Pero creo que la clave para la máxima flexibilidad es el manejo de eventos programables.

Para permitir que las cartas interactúen con eventos posteriores del juego, puede agregar una API de secuencias de comandos para agregar "ganchos de script" a ciertos eventos, como el comienzo y el final de las fases del juego, o ciertas acciones que los jugadores pueden realizar. Eso significa que el script que se ejecuta cuando se juega una carta puede registrar una función que se llama la próxima vez que se alcanza una fase específica. El número de funciones que se pueden registrar para cada evento debe ser ilimitado. Cuando hay más de uno, se les llama en su orden de registro (a menos, por supuesto, que haya una regla central del juego que diga algo diferente).

Debería ser posible registrar estos ganchos para todos los jugadores o solo para ciertos jugadores. También sugeriría agregar la posibilidad de que los ganchos decidan por sí mismos si deben seguir llamándose o no. En estos ejemplos, el valor de retorno de la función de enlace (verdadero o falso) se utiliza para expresar esto.

Su tarjeta de doble turno haría algo como esto:

add_event_hook('cleanup_phase_end', current_player, function {
     setNextPlayer(current_player); // make the player take another turn
     return false; // unregister this hook afterwards
});

(No tengo idea si Dominion tiene algo así como una "fase de limpieza" - en este ejemplo es la última fase hipotética del turno de los jugadores)

Una carta que permite a cada jugador robar una carta adicional al comienzo de su fase de robo se vería así:

add_event_hook('draw_phase_begin', NULL, function {
    drawCard(current_player); // draw a card
    return true; // keep doing this until the hook is removed explicitely
});

Una carta que hace que el jugador objetivo pierda un punto de golpe cada vez que juega una carta se vería así:

add_event_hook('play_card', target_player, function {
    changeHitPoints(target_player, -1); // remove a hit point
    return true; 
});

No evitará codificar algunas acciones del juego, como robar cartas o perder puntos de golpe, porque su definición completa, lo que significa exactamente "robar una carta", es parte de la mecánica central del juego. Por ejemplo, conozco algunos TCG donde cuando tienes que robar una carta por cualquier razón y tu mazo está vacío, pierdes el juego. Esta regla no está impresa en todas las cartas, lo que te hace robar cartas, porque está en el libro de reglas. Por lo tanto, tampoco debería tener que verificar esa condición de pérdida en el script de cada tarjeta. Verificar cosas como esa debería ser parte de la drawCard()función codificada (que, por cierto, también sería un buen candidato para un evento enganchable).

Por cierto: es poco probable que pueda planificar con anticipación para cada mecánica oscura que puedan surgir futuras ediciones , así que no importa lo que haga, aún tendrá que agregar una nueva funcionalidad para futuras ediciones de vez en cuando (en este caso, un minijuego de lanzamiento de confeti).

Philipp
fuente
1
Guau. Esa cosa del confeti del caos.
Jari Komppa
Excelente respuesta, @Philipp, y esto se encarga de una gran cantidad de cosas que se hacen en Dominion. Sin embargo, hay acciones que deben ocurrir de inmediato cuando se juega una carta, es decir, se juega una carta que obliga a otro jugador a entregar la carta superior de su biblioteca y que le permite al jugador actual decir "Conservarla" o "Descartarla". ¿Escribiría ganchos de eventos para encargarse de tales acciones inmediatas, o necesitaría idear métodos adicionales para escribir las tarjetas?
fnord
2
Cuando algo necesita suceder de inmediato, el script debe llamar a las funciones apropiadas directamente y no registrar una función de enlace.
Philipp
@JariKomppa: El conjunto Unglued era deliberadamente absurdo y estaba lleno de cartas locas que no tenían sentido. Mi favorita era una tarjeta que hacía que todos sufrieran un daño cuando decían una palabra en particular. Elegí 'el'.
Jack Aidley
9

Di este problema, un motor de juego de cartas computarizado flexible, algunos pensaron hace algún tiempo.

En primer lugar, un juego de cartas complejo como Chez Geek o Fluxx (y, creo, Dominion) requeriría que las cartas fueran programables. Básicamente, cada tarjeta vendría con su propio grupo de secuencias de comandos que pueden cambiar el estado del juego de varias maneras. Esto le permitiría darle al sistema algunas pruebas de futuro, ya que los scripts podrían hacer cosas que no puede pensar en este momento, pero que podrían venir en una expansión futura.

Segundo, el "giro" rígido puede estar causando problemas.

Necesitas algún tipo de "pila de turnos" que contenga los "turnos especiales", como "descartar 2 cartas". Cuando la pila está vacía, el turno normal predeterminado continúa.

En Fluxx, es completamente posible que un turno sea algo así como:

  • Elija N tarjetas (según lo establecido por las reglas actuales, cambiables mediante tarjetas)
  • Jugar N cartas (según lo establecido por las reglas actuales, cambiables mediante cartas)
    • Una de las cartas puede ser "toma 3, juega 2 de ellas"
      • Una de esas cartas bien puede ser "tomar otro turno"
    • Una de las cartas puede ser "descartar y robar"
  • Si cambias las reglas para elegir más cartas de las que hiciste cuando comenzó tu turno, elige más cartas
  • Si cambia las reglas para tener menos cartas en la mano, todos los demás deben descartarlas de inmediato.
  • Cuando finalice su turno, descarte las cartas hasta que tenga N cartas (cambiables mediante cartas, nuevamente), luego tome otro turno (si jugó la carta "tomar otro turno" en algún momento del desastre anterior).

..y así sucesivamente y así sucesivamente. Por lo tanto, diseñar una estructura de giro que pueda manejar el abuso anterior puede ser bastante complicado. Agregue a eso los numerosos juegos con cartas "siempre que" (como en "chez geek") donde las cartas "siempre" pueden interrumpir el flujo normal, por ejemplo, cancelando cualquier carta que se jugó por última vez.

Básicamente, comenzaría por diseñar una estructura de turno muy flexible, diseñarla para que pueda describirse como un guión (ya que cada juego necesitaría su propio "guión maestro" para manejar la estructura básica del juego). Entonces, cualquier tarjeta debe ser programable; la mayoría de las tarjetas probablemente no hagan nada extraño, pero otras sí. Las cartas también pueden tener varios atributos: si se pueden mantener en la mano, jugar "cuando sea", si se pueden almacenar como activos (como "guardianes" de fluxx, o varias cosas en 'chez geek' como comida) ...

En realidad, nunca comencé a implementar nada de esto, por lo que en la práctica puede encontrar muchos otros desafíos. La forma más fácil de comenzar sería comenzar con lo que sabe del sistema que desea implementar, e implementarlo de manera programable, estableciendo la menor cantidad de información posible, de modo que cuando llegue una expansión, no necesitará revisar El sistema base - mucho. =)

Jari Komppa
fuente
Esta es una gran respuesta, y hubiera aceptado las dos si hubiera podido. Rompí el empate al aceptar la respuesta de la persona con menor reputación :)
Apis Utilis
No hay problema, ya estoy acostumbrado. =)
Jari Komppa
0

Hearthstone parece hacer cosas fáciles de identificar y, sinceramente, creo que la mejor manera de lograr flexibilidad es a través de un motor ECS con un diseño orientado a datos. He estado tratando de hacer un clon de hearthstone y de lo contrario se ha demostrado que es imposible. Todos los casos de borde. Si te enfrentas a muchos de estos casos extraños, entonces esa es probablemente la mejor manera de hacerlo. Sin embargo, estoy bastante sesgado por la experiencia reciente que probó esta técnica.

Editar: es posible que ECS ni siquiera sea necesario dependiendo del tipo de flexibilidad y optimización que desee. Es solo una forma de lograr esto. DOD He pensado erróneamente como programación de procedimientos, aunque se relacionan mucho. Lo que quiero decir es. Que debería considerar eliminar OOP por completo o en su mayoría al menos y en su lugar centrar su atención en los datos y en cómo están organizados. Evitar la herencia y los métodos. En su lugar, enfóquese en las funciones públicas (sistemas) para manipular los datos de su tarjeta. Cada acción no es algo lógico o lógico de ningún tipo, sino datos sin procesar. Donde sus sistemas lo usan para realizar la lógica. El caso de cambio de número entero o el uso de un número entero para acceder a una matriz de punteros de función ayudan a descubrir la lógica deseada a partir de los datos de entrada de manera eficiente.

Las reglas básicas a seguir son que debe evitar vincular la lógica directamente con los datos, debe evitar que los datos dependan el uno del otro tanto como sea posible (se pueden aplicar excepciones), y que cuando desee una lógica flexible que se sienta fuera del alcance ... Considera convertirlo en datos.

Hay beneficios que se obtienen haciendo esto. Cada carta puede tener un valor de enumeración (s) o cadena (s) para representar sus acciones. Este pasante le permite diseñar tarjetas a través de archivos de texto o json y permite que el programa las importe automáticamente. Si hace que las acciones del jugador sean una lista de datos, esto le da aún más flexibilidad, especialmente si una tarjeta depende de la lógica pasada como lo hace hearthstone, o si desea guardar el juego o una repetición de un juego en cualquier momento. Hay potencial para crear IA más fácilmente. Especialmente cuando se usa un "sistema de utilidad" en lugar de un "árbol de comportamiento". La conexión en red también se vuelve más fácil porque en lugar de tener que descubrir cómo conseguir que se transfieran objetos polimórficos enteros y cómo se configuraría la serialización después del hecho, tu ya tienes tus objetos de juego no son más que simples datos que terminan siendo realmente fáciles de mover. Y por último, pero no menos importante, esto le permite optimizar más fácilmente porque en lugar de perder el tiempo preocupándose por el código, puede organizar mejor sus datos para que el procesador tenga más facilidad para lidiar con ellos. Python puede tener problemas aquí, pero busque "línea de caché" y cómo se relaciona con el desarrollo del juego. Tal vez no sea importante para la creación de prototipos, pero en el futuro será muy útil.

Algunos enlaces útiles.

Nota: ECS permite agregar / eliminar dinámicamente variables (llamadas componentes) en tiempo de ejecución. Un programa de ejemplo c de cómo "podría" verse ECS (hay muchas formas de hacerlo).

unsigned int textureID = ECSRegisterComponent("texture", sizeof(struct Texture));
unsigned int positionID = ECSRegisterComponent("position", sizeof(struct Point2DI));
for (unsigned int i = 0; i < 10; i++) {
    void *newEnt = ECSGetNewEntity();
    struct Point2DI pos = { 0 + i * 64, 0 };
    struct Texture tex;
    getTexture("test.png", &tex);
    ECSAddComponentToEntity(newEnt, &pos, positionID);
    ECSAddComponentToEntity(newEnt, &tex, textureID);
}
void *ent = ECSGetParentEntity(textureID, 3);
ECSDestroyEntity(ent);

Crea un grupo de entidades con datos de textura y posición y al final destruye una entidad que tiene un componente de textura que se encuentra en el tercer índice de la matriz de componentes de textura. Parece peculiar pero es una forma de hacer las cosas. Aquí hay un ejemplo de cómo renderizarías todo lo que tiene un componente de textura.

unsigned int textureCount;
unsigned int positionID = ECSGetComponentTypeFromName("position");
unsigned int textureID = ECSGetComponentTypeFromName("texture");
struct Texture *textures = ECSGetAllComponentsOfType(textureID, &textureCount);
for (unsigned int i = 0; i < textureCount; i++) {
    void *parentEntity = ECSGetParentEntity(textureID, i);
    struct Point2DI *drawPos = ECSGetComponentFromEntity(positionID, parentEntity);
    if (drawPos) {
        struct Texture *t = &textures[i];
        drawTexture(t, drawPos->x, drawPos->y);
    }
}
Blue_Pyro
fuente
1
Esta respuesta sería mejor si entrara en más detalles sobre cómo recomendaría configurar su ECS orientado a datos y aplicarlo para resolver este problema específico.
DMGregory
Actualizado gracias por señalar eso.
Blue_Pyro
En general, creo que es malo decirle a alguien "cómo" configurar este tipo de enfoque, pero dejar que diseñe su propia solución. Demuestra ser una buena forma de practicar y permite una solución potencialmente mejor al problema. Cuando se piensa en los datos más que en la lógica de esta manera, termina siendo que hay muchas formas de lograr lo mismo y todo depende de las necesidades de la aplicación. Así como el tiempo / conocimiento del programador.
Blue_Pyro