¿Cómo progresar un estado de juego de componente de entidad en un juego por turnos?

9

Hasta ahora, los sistemas de componentes de entidad que he usado han funcionado principalmente como el artemis de Java:

  • Todos los datos en componentes
  • Sistemas independientes sin estado (al menos en la medida en que no requieren entrada en la inicialización) iterando sobre cada entidad que contiene solo los componentes en los que está interesado un sistema en particular
  • Todos los sistemas procesan sus entidades una marca, luego todo comienza de nuevo.

Ahora estoy tratando de aplicar esto a un juego por turnos por primera vez, con toneladas de eventos y respuestas que deben ocurrir en un orden establecido en relación entre sí, antes de que el juego pueda seguir adelante. Un ejemplo:

El jugador A recibe daño de una espada. En respuesta a esto, la armadura de A entra en acción y reduce el daño recibido. La velocidad de movimiento de A también se reduce como resultado de debilitarse.

  • El daño recibido es lo que desencadena toda la interacción.
  • La armadura debe calcularse y aplicarse al daño entrante antes de que el daño se aplique al jugador
  • La reducción de la velocidad de movimiento no se puede aplicar a una unidad hasta después de que el daño se haya infligido, ya que depende de la cantidad de daño final.

Los eventos también pueden desencadenar otros eventos. La reducción del daño de la espada con armadura puede hacer que la espada se rompa (esto debe ocurrir antes de que se complete la reducción del daño), lo que a su vez puede causar eventos adicionales en respuesta a ella, esencialmente una evaluación recursiva de los eventos.

Con todo, esto parece conducir a algunos problemas:

  1. Muchos ciclos de procesamiento desperdiciados: la mayoría de los sistemas (salvo las cosas que siempre se ejecutan, como el renderizado) simplemente no tienen nada que valga la pena hacer cuando no es "su turno" para trabajar, y pasan la mayor parte del tiempo esperando que el juego entre Un estado de trabajo válido. Esto ensucia cada uno de esos sistemas con controles que siguen creciendo en tamaño a medida que se agregan más estados al juego.
  2. Para averiguar si un sistema puede procesar entidades que están presentes en el juego, necesitan alguna forma de monitorear otros estados de entidad / sistema no relacionados (el sistema responsable del daño debe saber si la armadura se ha aplicado o no). Esto confunde los sistemas con múltiples responsabilidades o crea la necesidad de sistemas adicionales sin otro propósito que escanear la colección de entidades después de cada ciclo de procesamiento y comunicarse con un conjunto de oyentes diciéndoles cuándo está bien hacer algo.

Los dos puntos anteriores suponen que los sistemas funcionan en el mismo conjunto de entidades, que terminan cambiando de estado usando banderas en sus componentes.

Otra forma de resolverlo sería agregar / eliminar componentes (o crear entidades completamente nuevas) como resultado de un solo trabajo de sistemas para progresar en el estado de los juegos. Esto significa que siempre que un sistema tenga una entidad coincidente, sabe que puede procesarlo.

Sin embargo, esto hace que los sistemas sean responsables de activar sistemas posteriores, lo que dificulta razonar sobre el comportamiento de los programas, ya que los errores no se mostrarán como resultado de una sola interacción del sistema. Agregar nuevos sistemas también se vuelve más difícil ya que no se pueden implementar sin saber exactamente cómo afectan a otros sistemas (y es posible que los sistemas anteriores tengan que modificarse para activar los estados en los que está interesado el nuevo sistema), lo que frustra el propósito de tener sistemas separados con una sola tarea

¿Es algo con lo que tendré que vivir? Todos los ejemplos de ECS que he visto han sido en tiempo real, y es realmente fácil ver cómo funciona este bucle de una iteración por juego en tales casos. Y todavía lo necesito para renderizar, simplemente parece no apto para sistemas que pausan la mayoría de los aspectos de sí mismos cada vez que sucede algo.

¿Hay algún patrón de diseño para mover el estado del juego hacia adelante que sea adecuado para esto, o debería simplemente sacar toda la lógica del bucle y, en su lugar, activarla solo cuando sea necesario?

Aeris130
fuente
Realmente no desea sondear para que suceda un evento. Un evento solo ocurre cuando ocurre. ¿Artemis no permite que los sistemas se comuniquen entre sí?
Sidar
Lo hace, pero solo uniéndolos mediante métodos.
Aeris130

Respuestas:

3

Mi consejo aquí proviene de la experiencia pasada en un proyecto RPG donde usamos un sistema de componentes. Diré que odié trabajar en ese código del lado del juego porque era un código de espagueti. Así que no estoy ofreciendo una gran respuesta aquí, solo una perspectiva:

La lógica que describe para manejar el daño de la espada a un jugador ... parece que un sistema debería estar a cargo de todo eso.

En algún lugar, hay una función HandleWeaponHit (). Accedería al ArmorComponent de la entidad del jugador para obtener la armadura relevante. Accedería al Componente de Armas de la entidad de armas atacantes para quizás destruir el arma. Después de calcular el daño final, tocaría el Componente de Movimiento para que el jugador logre la reducción de velocidad.

En cuanto a los ciclos de procesamiento desperdiciados ... HandleWeaponHit () solo debe activarse cuando sea necesario (al detectar el golpe de la espada).

Quizás el punto que estoy tratando de hacer es: seguramente quieres un lugar en el código donde puedas poner un punto de quiebre, golpearlo, y luego proceder a pasar por toda la lógica que se supone que debe ejecutarse cuando ocurre un golpe de espada. En otras palabras, la lógica no debe estar dispersa en todas las funciones tick () de múltiples sistemas.

Eric Undersander
fuente
Hacerlo de esta manera haría que la función hit () funcionara a medida que se agregaran más comportamientos. Digamos que hay un enemigo que cae de risa cada vez que una espada golpea un objetivo (cualquier objetivo) dentro de su línea de visión. ¿Debería HandleWeaponHit ser realmente responsable de desencadenar eso?
Aeris130
1
Tienes una secuencia de combate estrechamente enredada, así que sí, el golpe es responsable de los efectos desencadenantes. No todo tiene que dividirse en pequeños sistemas, deje que este sistema maneje esto porque realmente es su "Sistema de Combate" y maneja ... Combate ...
Patrick Hughes
3

Es una pregunta de hace un año, pero ahora me enfrento a los mismos problemas con mi juego casero mientras estudio ECS, por lo tanto, algo de necromanía. Esperemos que termine en una discusión o al menos algunos comentarios.

No estoy seguro si viola los conceptos de ECS, pero ¿y si:

  • Agregue un EventBus para permitir que los Sistemas emitan / suscriban objetos de evento (datos puros de hecho, pero no un componente, supongo)
  • Crear componentes para cada estado intermedio

Ejemplo:

  • UserInputSystem dispara un evento de ataque con [DamageDealerEntity, DamageReceiverEntity, Skill / Weapon used info]
  • CombatSystem está suscrito y calcula la posibilidad de evasión de DamageReceiver. Si la evasión falla, entonces se activa el evento de Daño con los mismos parámetros.
  • DamageSystem está suscrito a dicho evento y, por lo tanto, se activa
  • DamageSystem usa Fuerza, Daño BaseWeapon, su tipo, etc. y lo escribe en un nuevo IncomingDamageComponent con [DamageDealerEntity, FinalOutgoingDamage, DamageType] y lo adjunta a la Entidad / Entidades del receptor de daños
  • DamageSystem dispara un OutgoingDamageCalculated
  • ArmorSystem es activado por él, recoge una Entidad receptora o busca por este aspecto IncomingDamage en Entities para recoger IncomingDamageComponent (el último podría ser mejor para múltiples ataques con propagación) y calcula la armadura y el daño aplicado a él. Opcionalmente desencadena eventos para romper la espada
  • ArmorSystems elimina IncomingDamageComponent en cada entidad y lo reemplaza con DamageReceivedComponent con números finales calculados que afectarán HP y la reducción de velocidad de las heridas
  • ArmorSystems envía un evento IncomingDamageCalculated
  • El sistema de velocidad está suscrito y recalcula la velocidad
  • HealthSystem está suscrito y disminuye el HP real
  • etc.
  • De alguna manera limpiar

Pros:

  • El sistema se dispara entre sí proporcionando datos intermedios para eventos de cadena complejos
  • Desacoplamiento por EventBus

Contras:

  • Siento que mezclo dos formas de pasar cosas: en los parámetros de eventos y en los componentes temporales. Puede ser un lugar débil. En teoría, para mantener las cosas homogéneas, podría disparar solo eventos enum sin datos para que los Sistemas encuentren los parámetros implícitos en los componentes de la Entidad por aspecto ... Sin embargo, no estoy seguro de si está bien
  • No estoy seguro de cómo saber si todos los sistemas potencialmente interesados ​​han procesado IncomingDamageCalculated para poder limpiarlo y permitir que suceda el próximo turno. Tal vez algún tipo de verificación en CombatSystem ...
Sergey Yakovlev
fuente
2

Publicando la solución que finalmente decidí, similar a la de Yakovlev.

Básicamente, terminé usando un sistema de eventos ya que me pareció muy intuitivo seguir su lógica durante los turnos. El sistema terminó siendo responsable de las unidades en el juego que se adhirieron a la lógica por turnos (jugadores, monstruos y cualquier cosa con la que puedan interactuar), las tareas en tiempo real como la representación y el sondeo de entrada se colocaron en otro lugar.

Los sistemas implementan un método onEvent que toma un evento y una entidad como entrada, lo que indica que la entidad ha recibido el evento. Cada sistema también se suscribe a eventos y entidades con un conjunto específico de componentes. El único punto de interacción disponible para los sistemas es el administrador de entidades singleton, utilizado para enviar eventos a entidades y para recuperar componentes de una entidad específica.

Cuando el administrador de la entidad recibe un evento junto con la entidad a la que se envía, coloca el evento al final de una cola. Si bien hay eventos en la cola, el evento principal se recupera y se envía a cada sistema que se suscribe al evento y está interesado en el conjunto de componentes de la entidad que recibe el evento. Esos sistemas a su vez pueden procesar los componentes de la entidad, así como enviar eventos adicionales al gerente.

Ejemplo: el jugador recibe daño, por lo que la entidad del jugador recibe un evento de daño. El DamageSystem se suscribe a los eventos de daños enviados a cualquier entidad con el componente de salud y tiene un método onEvent (entidad, evento) que reduce la salud en el componente de las entidades en la cantidad especificada en el evento.

Esto facilita la inserción de un sistema de armadura que se suscribe a los eventos de daño enviados a entidades con un componente de armadura. Su método onEvent reduce el daño en el evento por la cantidad de armadura en el componente. Esto significa que especificar el orden en que los sistemas reciben eventos impacta la lógica del juego, ya que el sistema de armadura debe procesar el evento de daño antes que el sistema de daño para que funcione.

Sin embargo, a veces un sistema tiene que salir de la entidad receptora. Para continuar con mi respuesta a Eric Undersander, sería trivial agregar un sistema que acceda al mapa del juego y busque entidades con el Componente FallsDownLaughing dentro de x espacios de la entidad que recibe el daño, y luego enviarles un Evento FallDownLaughingEvent. Este sistema tendría que ser programado para recibir el evento después del sistema de daños, si el evento de daño no se ha cancelado en ese punto, el daño fue infligido.

Un problema que surgió fue cómo asegurarse de que los eventos de respuesta se procesen en el orden en que se envían, dado que algunas respuestas pueden generar respuestas adicionales. Ejemplo:

El jugador se mueve, lo que provoca que se envíe un evento de movimiento a la entidad del jugador y que el sistema de movimiento lo recoja.

En la cola: movimiento

Si se permite el movimiento, el sistema ajusta la posición de los jugadores. Si no (el jugador intentó pasar a un obstáculo), marca el evento como cancelado, lo que hace que el administrador de la entidad lo descarte en lugar de enviarlo a los sistemas posteriores. Al final de la lista de sistemas interesados ​​en el evento se encuentra el TurnFinishedSystem, que confirma que el jugador ha gastado su turno para mover al personaje, y que su turno ha terminado. Esto da como resultado un evento TurnOver que se envía a la entidad del jugador y se coloca en la cola.

En cola: TurnOver

Ahora diga que el jugador pisó una trampa, causando daño. TrapSystem recibe el mensaje de movimiento antes de TurnFinishedSystem, por lo que el evento de daño se envía primero. Ahora la cola en cambio se ve así:

En cola: Daño, TurnOver

Todo está bien hasta ahora, el evento de daño se procesará y luego el turno termina. Sin embargo, ¿qué pasa si se envían eventos adicionales como respuesta al daño? Ahora la cola del evento se vería así:

En cola: Daño, TurnOver, ResponseToDamage

En otras palabras, el turno terminaría antes de que se procesara cualquier respuesta al daño.

Para resolver esto terminé usando dos métodos de envío de eventos: enviar (evento, entidad) y responder (evento, eventToRespondTo, entidad).

Cada evento mantiene un registro de eventos anteriores en una cadena de respuesta, y cada vez que se utiliza el método respond (), el evento al que se responde (y cada evento en su cadena de respuesta) termina en la cabeza de la cadena en el evento utilizado para responder con. El evento de movimiento inicial no tiene tales eventos. La respuesta de daño posterior tiene el evento de movimiento en su lista.

Además de eso, se usa una matriz de longitud variable para contener múltiples colas de eventos. Cada vez que el administrador recibe un evento, el evento se agrega a una cola en un índice de la matriz que coincide con la cantidad de eventos en la cadena de respuesta. Por lo tanto, el evento de movimiento inicial se agrega a la cola en [0], y el daño, así como los eventos TurnOver, se agregan a una cola separada en [1] ya que ambos se enviaron como respuestas al movimiento.

Cuando se envían las respuestas al evento de daño, esos eventos contendrán tanto el evento de daño en sí como el movimiento, colocándolos en una cola en el índice [2]. Mientras el índice [n] tenga eventos en su cola, esos eventos serán procesados ​​antes de pasar a [n-1]. Esto da un orden de procesamiento de:

Movimiento -> Daño [1] -> ResponseToDamage [2] -> [2] está vacío -> TurnOver [1] -> [1] está vacío -> [0] está vacío

Aeris130
fuente