Crear un juego de hockey AI utilizando comportamientos de dirección ataque

En este tutorial, continuamos codificando la inteligencia artificial para un juego de hockey que utiliza comportamientos de dirección y máquinas de estados finitos. En esta parte de la serie, aprenderás acerca de la IA requerida por las entidades del juego para coordinar un ataque, lo que implica interceptar y llevar el disco al objetivo del oponente..

Unas pocas palabras sobre atacar

Coordinar y realizar un ataque en un juego deportivo cooperativo es una tarea muy compleja. En el mundo real, cuando los humanos juegan un juego de hockey, hacen varios Decisiones basadas en muchas variables..

Esas decisiones involucran cálculos y comprensión de lo que está sucediendo. Un humano puede decir por qué un oponente se está moviendo en base a las acciones de otro, por ejemplo, "se está moviendo para estar en una mejor posición estratégica". No es trivial trasladar esa comprensión a una computadora.

Como consecuencia, si intentamos codificar la IA para seguir todos los matices y percepciones humanas, el resultado será una enorme y aterradora pila de código. Además, el resultado puede no ser preciso o fácilmente modificable.

Esa es la razón por la que nuestro ataque AI intentará imitar al resultado de un grupo de humanos jugando, no la percepción humana en sí misma. Ese enfoque conducirá a aproximaciones, pero el código será más fácil de entender y modificar. El resultado es suficientemente bueno para varios casos de uso..

Organizando el ataque con estados

Dividiremos el proceso de ataque en partes más pequeñas, y cada uno realizará una acción muy específica. Esas piezas son los estados de una máquina de estados finitos basada en pila. Como se explicó anteriormente, cada estado producirá una fuerza de dirección que hará que el atleta se comporte en consecuencia.

La orquestación de esos estados y las condiciones para cambiar entre ellos definirán el ataque. La siguiente imagen presenta el FSM completo utilizado en el proceso:

Una máquina de estados finitos basada en la pila que representa el proceso de ataque.

Como se ilustra en la imagen, las condiciones para cambiar entre los estados se basarán únicamente en la distancia y la propiedad del disco. Por ejemplo, el equipo tiene el pucko el disco está demasiado lejos.

El proceso de ataque estará compuesto por cuatro estados: ocioso, ataque, robar, y continuePuck. los ocioso El estado ya se implementó en el tutorial anterior, y es el punto de inicio del proceso. A partir de ahí, un atleta cambiará a ataque si el equipo tiene el puck, a robar si el equipo del oponente tiene el puck, o para continuePuck Si el disco no tiene dueño y está lo suficientemente cerca para ser recogido..

los ataque El estado representa un movimiento ofensivo. Mientras que en ese estado, el atleta que lleva el puck (llamado líder) tratará de alcanzar la meta del oponente. Los compañeros de equipo avanzarán, tratando de apoyar la acción..

los robar El estado representa algo entre un movimiento defensivo y un movimiento ofensivo. Mientras se encuentre en ese estado, un atleta se enfocará en perseguir al oponente que lleva el puck. El objetivo es recuperar el puck, para que el equipo pueda comenzar a atacar de nuevo..

Finalmente, el continuePuck El estado no está relacionado con el ataque o la defensa; simplemente guiará a los atletas cuando el puck no tenga dueño. Mientras se encuentre en ese estado, un atleta intentará obtener el disco que se mueve libremente en la pista (por ejemplo, después de ser golpeado por el palo de alguien).

Actualización del estado inactivo

los ocioso El estado que se implementó previamente no tuvo transiciones. Dado que este estado es el punto de partida para toda la IA, actualicemos y podamos cambiar a otros estados.

los ocioso El estado tiene tres transiciones:

 El estado inactivo y sus transiciones en el FSM que describen el proceso de ataque.

Si el equipo del atleta tiene el puck., ocioso debe ser sacado del cerebro y ataque debe ser empujado Del mismo modo, si el equipo del oponente tiene el puck., ocioso debe ser reemplazado por robar. La transición restante ocurre cuando nadie posee el puck y está cerca del atleta; en ese caso, continuePuck debe ser empujado hacia el cerebro.

La versión actualizada de ocioso es como sigue (todos los demás estados serán implementados más adelante):

clase Atleta // (...) función privada inactiva (): void var aPuck: Puck = getPuck (); stopAndlookAt (aPuck); // Esto es un truco para ayudar a probar la IA. if (mStandStill) return; // ¿El puck tiene dueño? if (getPuckOwner ()! = null) // Sí, lo ha hecho. mBrain.popState (); if (doesMyTeamHaveThePuck ()) // ¡Mi equipo acaba de recibir el disco, es hora de atacar! mBrain.pushState (ataque);  else // El equipo oponente consiguió el puck, intentemos robarlo. mBrain.pushState (stealPuck);  else if (distancia (esto, aPuck) < 150)  // The puck has no owner and it is nearby. Let's pursue it. mBrain.popState(); mBrain.pushState(pursuePuck);   private function attack() :void   private function stealPuck() :void   private function pursuePuck() :void   

Continuemos con la implementación de los otros estados..

Persiguiendo el disco

Ahora que el atleta ha adquirido cierta percepción sobre el medio ambiente y puede cambiar de ocioso a cualquier estado, concentrémonos en perseguir el disco cuando no tiene dueño.

Un atleta cambiará a continuePuck Inmediatamente después de que comience el partido, porque el disco se colocará en el centro de la pista sin dueño. los continuePuck El estado tiene tres transiciones:

El estado pursopuck y sus transiciones en el FSM que describen el proceso de ataque.

La primera transicion es el disco está demasiado lejos, y trata de simular lo que sucede en un juego real con respecto a perseguir el disco. Por razones estratégicas, generalmente el atleta más cercano al puck es el que trata de atraparlo, mientras que los otros esperan o intentan ayudar..

Sin cambiar a ocioso cuando el puck es distante, todos los atletas controlados por la IA lo perseguirían al mismo tiempo, incluso si están lejos de él. Comprobando la distancia entre el deportista y el puck., continuePuck salta del cerebro y empuja ocioso cuando el puck es demasiado distante, lo que significa que el atleta simplemente se "dio por vencido" y lo siguió:

class Athlete // (...) function privada pursPuck (): void var aPuck: Puck = getPuck (); if (distance (this, aPuck)> 150) // Puck está demasiado lejos de nuestra posición actual, así que abandonemos // persiguiendo el puck y esperamos que alguien esté más cerca para obtener el puck // para nosotros. mBrain.popState (); mBrain.pushState (inactivo);  else // El disco está cerca, intentemos agarrarlo.  // (…)

Cuando el disco está cerca, el atleta debe ir tras él, lo que puede lograrse fácilmente con el comportamiento de búsqueda. Usando la posición del disco como el destino de búsqueda, el atleta lo seguirá con gracia y ajustará su trayectoria a medida que el disco se mueve:

class Athlete // (...) function privada pursPuck (): void var aPuck: Puck = getPuck (); mBoid.steering = mBoid.steering + mBoid.separation (); if (distance (this, aPuck)> 150) // Puck está demasiado lejos de nuestra posición actual, así que abandonemos // persiguiendo el puck y esperamos que alguien esté más cerca para obtener el puck // para nosotros. mBrain.popState (); mBrain.pushState (inactivo);  else // El disco está cerca, intentemos agarrarlo. if (aPuck.owner == null) // ¡Nadie tiene el disco, es nuestra oportunidad de buscarlo y obtenerlo! mBoid.steering = mBoid.steering + mBoid.seek (aPuck.position);  else // Alguien acaba de recibir el puck. Si el nuevo propietario del puck pertenece a mi equipo, // deberíamos cambiar a 'atacar', de lo contrario debería cambiar a 'stealPuck' // e intentar recuperar el puck. mBrain.popState (); mBrain.pushState (doesMyTeamHaveThePuck ()? attack: stealPuck); 

Las dos transiciones restantes en el continuePuck estado, el equipo tiene el puck y el oponente tiene el disco, están relacionados con el puck que se captura durante el proceso de búsqueda. Si alguien atrapa el puck, el atleta debe hacer estallar el continuePuck Estado y empujar uno nuevo en el cerebro. 

El estado a ser empujado depende de la propiedad del puck. Si la llamada a doesMyTeamHaveThePuck () devoluciones cierto, significa que un compañero de equipo consiguió el disco, por lo que el atleta debe empujar ataque, lo que significa que es hora de dejar de perseguir el disco y comenzar a avanzar hacia la meta del oponente. Si un oponente tiene el puck, el atleta debe empujar robar, Lo que hará que el equipo intente recuperar el disco..

Como una pequeña mejora, los atletas no deben permanecer demasiado cerca unos de otros durante el continuePuck Estado, porque un movimiento de persecución "lleno de gente" no es natural. Añadiendo separación a la fuerza de dirección del estado (línea 6 en el código de arriba) asegura que los atletas mantendrán una distancia mínima entre ellos.

El resultado es un equipo que es capaz de perseguir el disco. Por el bien de la prueba, en esta demostración, el disco se coloca en el centro de la pista cada pocos segundos, para que los atletas se muevan continuamente:

Atacando con el puck

Después de obtener el puck, un atleta y su equipo deben avanzar hacia la meta del oponente para marcar. Ese es el propósito de la ataque estado:

El estado de ataque y sus transiciones en el FSM que describe el proceso de ataque..

los ataque El estado tiene solo dos transiciones: el oponente tiene el disco y puck no tiene dueño. Dado que el estado está diseñado exclusivamente para hacer que los atletas se muevan hacia la meta del oponente, no tiene sentido seguir atacando si el puck ya no está bajo la posesión del equipo.

Con respecto al movimiento hacia la meta del oponente: el atleta que lleva el puck (líder) y los compañeros que lo ayudan deben comportarse de manera diferente. El líder debe alcanzar la meta del oponente, y los compañeros de equipo deben ayudarlo en el camino..

Esto se puede implementar verificando si el atleta que ejecuta el código tiene el puck:

class Athlete // (…) ataque de función privada (): void var aPuckOwner: Athlete = getPuckOwner (); // ¿Tiene el puck un dueño? if (aPuckOwner! = null) // Sí, lo ha hecho. Averigüemos si el propietario pertenece al equipo de los oponentes. if (doesMyTeamHaveThePuck ()) if (amIThePuckOwner ()) // ¡Mi equipo tiene el puck y yo soy el que lo tiene! Avancemos // hacia la meta del oponente. mBoid.steering = mBoid.steering + mBoid.seek (getOpponentGoalPosition ());  else // Mi equipo tiene el puck, pero un compañero lo tiene. Vamos a seguirlo // para darle algo de apoyo durante el ataque. mBoid.steering = mBoid.steering + mBoid.followLeader (aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation ();  else // ¡El oponente tiene el puck! Detén el ataque // e intenta robarlo. mBrain.popState (); mBrain.pushState (stealPuck);  else else // Puck no tiene propietario, por lo que no tiene sentido mantener // atacando. Es hora de reorganizar y comenzar a perseguir el disco. mBrain.popState (); mBrain.pushState (pursuePuck); 

Si amIThePuckOwner () devoluciones cierto (línea 10), el atleta que ejecuta el código tiene el puck. En ese caso, solo buscará la posición de gol del oponente. Esa es prácticamente la misma lógica que se utiliza para perseguir el disco en el continuePuck estado.

Si amIThePuckOwner () devoluciones falso, El atleta no tiene el puck, por lo que debe ayudar al líder. Ayudar al líder es una tarea complicada, así que la simplificaremos. Un atleta asistirá al líder solo buscando una posición por delante de él:

Compañeros de equipo ayudando al líder.

A medida que el líder se mueva, estará rodeado de compañeros de equipo a medida que sigan el adelante punto. Esto le da al líder algunas opciones para pasarle el disco si hay problemas. Como en un juego real, los compañeros de equipo que lo rodean también deben mantenerse fuera del camino del líder..

Este patrón de asistencia se puede lograr agregando una versión ligeramente modificada del comportamiento de líder (línea 18). La única diferencia es que los atletas seguirán un punto. adelante del líder, en lugar de uno detrás de él como se implementó originalmente en ese comportamiento.

Los atletas que asisten al líder también deben mantener una distancia mínima entre ellos. Eso se implementa agregando una fuerza de separación (línea 19).

El resultado es un equipo capaz de moverse hacia la meta del oponente, sin aglomeraciones y mientras simula un movimiento de ataque asistido:

Mejorando el soporte de ataque

La implementación actual del ataque El estado es suficientemente bueno para algunas situaciones, pero tiene un defecto. Cuando alguien atrapa el puck, se convierte en el líder y es seguido inmediatamente por sus compañeros de equipo..

¿Qué sucede si el líder se está moviendo hacia su propia meta cuando atrapa el puck? Eche un vistazo más de cerca a la demostración anterior y observe el patrón poco natural cuando los compañeros comienzan a seguir al líder..

Cuando el líder atrapa el puck, el comportamiento de búsqueda toma algún tiempo para corregir la trayectoria del líder y efectivamente hacer que se mueva hacia la meta del oponente. Incluso cuando el líder está "maniobrando", los compañeros de equipo intentarán buscar su adelante punto, lo que significa que se moverán hacia su propia objetivo (o el lugar que el líder está mirando).

Cuando el líder esté finalmente en posición y listo para avanzar hacia la meta del oponente, los compañeros de equipo estarán "maniobrando" para seguir al líder. El líder se moverá sin el apoyo de su compañero de equipo mientras los demás estén ajustando sus trayectorias..

Esta falla puede solucionarse verificando si el compañero de equipo está delante del líder cuando el equipo recupera el puck. Aquí, la condición "adelante" significa "más cerca del objetivo del oponente":

class Athlete // (…) la función privada isAheadOfMe (theBoid: Boid): Boolean var aTargetDistance: Number = distance (getOpponentGoalPosition (), theBoid); var aMyDistance: Number = distance (getOpponentGoalPosition (), mBoid.position); volver aTargetDistance <= aMyDistance;  private function attack() :void  var aPuckOwner :Athlete = getPuckOwner(); // Does the puck have an owner? if (aPuckOwner != null)  // Yeah, it has. Let's find out if the owner belongs to the opponents team. if (doesMyTeamHaveThePuck())  if (amIThePuckOwner())  // My team has the puck and I am the one who has it! Let's move // towards the opponent's goal. mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition());  else  // My team has the puck, but a teammate has it. Is he ahead of me? if (isAheadOfMe(aPuckOwner.boid))  // Yeah, he is ahead of me. Let's just follow him to give some support // during the attack. mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation();  else  // Nope, the teammate with the puck is behind me. In that case // let's hold our current position with some separation from the // other, so we prevent crowding. mBoid.steering = mBoid.steering + mBoid.separation();    else  // The opponent has the puck! Stop the attack // and try to steal it. mBrain.popState(); mBrain.pushState(stealPuck);   else  // Puck has no owner, so there is no point to keep // attacking. It's time to re-organize and start pursuing the puck. mBrain.popState(); mBrain.pushState(pursuePuck);   

Si el líder (quien es el propietario del puck) está delante del atleta que maneja el código, entonces el atleta debe seguir al líder tal como lo hacía antes (líneas 27 y 28). Si el líder está detrás de él, el atleta debe mantener su posición actual, manteniendo una distancia mínima entre los demás (línea 33).

El resultado es un poco más convincente que el inicial. ataque implementación:

Propina: Al ajustar los cálculos de distancia y comparaciones en el isAheadOfMe () método, es posible modificar la forma en que los atletas mantienen sus posiciones actuales.

Robando el puck

El estado final en el proceso de ataque es robar, que se activa cuando el equipo contrario tiene el puck. El propósito principal de la robar estado es robar el disco del oponente que lo lleva, para que el equipo pueda comenzar a atacar de nuevo:

 El estado de stealPuck y sus transiciones en el FSM que describe el proceso de ataque.

Dado que la idea detrás de este estado es robar el puck del oponente, si el equipo recupera el puck o se libera (es decir, no tiene dueño), robar saldrá del cerebro y empujará el estado correcto para lidiar con la nueva situación:

class Athlete // (…) private function stealPuck (): void // ¿Tiene el puck algún dueño? if (getPuckOwner ()! = null) // Sí, lo tiene, pero ¿quién lo tiene? if (doesMyTeamHaveThePuck ()) // Mi equipo tiene el puck, así que es hora de dejar de intentar robar // el puck y comenzar a atacar. mBrain.popState (); mBrain.pushState (ataque);  else // Un oponente tiene el puck. var aOpponentLeader: Athlete = getPuckOwner (); // Persigámoslo manteniendo una cierta separación de // los demás para evitar que todos ocupen la misma // posición en la búsqueda. mBoid.steering = mBoid.steering + mBoid.pursuit (aOpponentLeader.boid); mBoid.steering = mBoid.steering + mBoid.separation ();  else // El puck no tiene dueño, probablemente se está ejecutando libremente en la pista. // No tiene sentido seguir intentando robarlo, así que terminemos el estado 'stealPuck' // y cambiemos a 'pursopuck'. mBrain.popState (); mBrain.pushState (pursuePuck); 

Si el puck tiene un propietario y pertenece al equipo del oponente, el atleta debe perseguir al líder contrario e intentar robar el puck. Para perseguir al líder del oponente, un atleta debe predecir donde estará en un futuro cercano, para que pueda ser interceptado en su trayectoria. Eso es diferente de solo buscar al líder opuesto.

Afortunadamente, esto se puede lograr fácilmente con el comportamiento de búsqueda (línea 19). Mediante el uso de una fuerza de persecución en el robar Estado, los atletas tratarán de interceptar el líder del oponente, en lugar de simplemente seguirlo:

Prevenir un movimiento de robo atestado

La implementación actual de robar Funciona, pero en un juego real solo uno o dos atletas se acercan al líder oponente para robar el puck. El resto del equipo permanece en las áreas circundantes tratando de ayudar, lo que evita un patrón de robo abarrotado..

Se puede arreglar agregando una verificación de distancia (línea 17) antes de la búsqueda del líder del oponente:

class Athlete // (…) private function stealPuck (): void // ¿Tiene el puck algún dueño? if (getPuckOwner ()! = null) // Sí, lo tiene, pero ¿quién lo tiene? if (doesMyTeamHaveThePuck ()) // Mi equipo tiene el puck, así que es hora de dejar de intentar robar // el puck y comenzar a atacar. mBrain.popState (); mBrain.pushState (ataque);  else // Un oponente tiene el puck. var aOpponentLeader: Athlete = getPuckOwner (); // ¿Está el oponente con el puck cerca de mí? if (distance (aOpponentLeader, this) < 150)  // Yeah, he is close! Let's pursue him while mantaining a certain // separation from the others to avoid that everybody will ocuppy the same // position in the pursuit. mBoid.steering = mBoid.steering.add(mBoid.pursuit(aOpponentLeader.boid)); mBoid.steering = mBoid.steering.add(mBoid.separation(50));  else  // No, he is too far away. In the future, we will switch // to 'defend' and hope someone closer to the puck can // steal it for us. // TODO: mBrain.popState(); // TODO: mBrain.pushState(defend);    else  // The puck has no owner, it is probably running freely in the rink. // There is no point to keep trying to steal it, so let's finish the 'stealPuck' state // and switch to 'pursuePuck'. mBrain.popState(); mBrain.pushState(pursuePuck);   

En lugar de perseguir ciegamente al líder del oponente, un atleta verificará si la distancia entre él y el líder del oponente es menor que, digamos, 150. Si eso es cierto, la persecución ocurre normalmente, pero si la distancia es mayor que 150, significa que el atleta está demasiado lejos del líder oponente.

Si eso sucede, no tiene sentido continuar intentando robar el puck, ya que está demasiado lejos y es probable que ya haya compañeros de equipo tratando de hacer lo mismo. La mejor opción es hacer estallar. robar desde el cerebro y empujar el defensa Estado (que se explicará en el siguiente tutorial). Por ahora, un atleta mantendrá su posición actual si el líder oponente está demasiado lejos.

El resultado es un patrón de robo más convincente y natural (sin aglomeraciones):

Evitar a los oponentes durante el ataque

Hay un último truco que los atletas deben aprender para atacar de manera efectiva. En este momento, se mueven hacia la meta del oponente sin considerar a los oponentes en el camino. Un oponente debe ser visto como una amenaza, y debe ser evitado.

Usando el comportamiento de evitación de colisión, los atletas pueden esquivar a los oponentes mientras se mueven:

Comportamiento para evitar colisiones usado para evitar oponentes.

Los oponentes serán vistos como obstáculos circulares. Como resultado de la naturaleza dinámica de los comportamientos de dirección, que se actualizan en cada ciclo de juego, el patrón de evitación funcionará con gracia y sin problemas para mover obstáculos (como es el caso aquí).

Para que los atletas eviten oponentes (obstáculos), se debe agregar una sola línea al estado de ataque (línea 14):

class Athlete // (…) ataque de función privada (): void var aPuckOwner: Athlete = getPuckOwner (); // ¿Tiene el puck un dueño? if (aPuckOwner! = null) // Sí, lo ha hecho. Averigüemos si el propietario pertenece al equipo de los oponentes. if (doesMyTeamHaveThePuck ()) if (amIThePuckOwner ()) // ¡Mi equipo tiene el puck y yo soy el que lo tiene! Avancemos // hacia la meta del oponente, codificando cualquier oponente en el camino. mBoid.steering = mBoid.steering + mBoid.seek (getOpponentGoalPosition ()); mBoid.steering = mBoid.steering + mBoid.collisionAvoidance (getOpponentTeam (). members);  else // Mi equipo tiene el puck, pero un compañero lo tiene. ¿Está él delante de mí? if (isAheadOfMe (aPuckOwner.boid)) // Sí, él está delante de mí. Vamos a seguirlo para darle algo de apoyo // durante el ataque. mBoid.steering = mBoid.steering + mBoid.followLeader (aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation ();  else // No, el compañero de equipo con el puck está detrás de mí. En ese caso, // mantengamos nuestra posición actual con una cierta separación de la // otra, por lo que evitamos el hacinamiento. mBoid.steering = mBoid.steering + mBoid.separation ();  else // ¡El oponente tiene el puck! Detén el ataque // e intenta robarlo. mBrain.popState (); mBrain.pushState (stealPuck);  else else // Puck no tiene propietario, por lo que no tiene sentido mantener // atacando. Es hora de reorganizar y comenzar a perseguir el disco. mBrain.popState (); mBrain.pushState (pursuePuck); 

Esta línea agregará una fuerza de evitación de colisión al atleta, que se combinará con las fuerzas que ya existen. Como resultado, el atleta evitará obstáculos al mismo tiempo que busca la meta del oponente.

A continuación se muestra una demostración de un atleta que corre el ataque estado. Los oponentes son inamovibles para resaltar el comportamiento de evitar colisiones:

Conclusión

Este tutorial explica la implementación del patrón de ataque utilizado por los atletas para robar y llevar el disco hacia la meta del oponente. Usando una combinación de conductas de dirección, los atletas ahora pueden realizar patrones de movimiento complejos, como seguir a un líder o perseguir al oponente con el puck.

Como se discutió anteriormente, la implementación del ataque apunta a simular lo que los humanos hacer, Así que el resultado es una aproximación de un juego real. Al ajustar individualmente los estados que componen el ataque, puede producir una mejor simulación, o una que se ajuste a sus necesidades.

En el siguiente tutorial, aprenderá cómo hacer defender a los atletas. La IA se convertirá en característica completa, capaz de atacar y defender, lo que resultará en una partida con equipos controlados por la IA al 100% que juegan entre sí..

Referencias

  • Sprite: Estadio de hockey en GraphicRiver
  • Sprites: jugadores de hockey por Taylor J Glidden