< Volver 

Sword Masters: Zombies

2024-09-15

Las cacerías de zombies de Sword Masters sacan oleadas de ocho tipos de enemigos. Cada zombie persigue en saltos de a lo sumo 12 studs sobre SimplePath, vuelve a planear al final de cada salto y tiene un watchdog de un segundo que convierte cualquier falla silenciosa del pathfinding en un reintento.

LuauRobloxGameplayAIPathfinding
Ir a vista rápida

Zombies que vuelven a planear cada doce studs.

Advertencia

Sword Masters quedó abandonado en octubre de 2024, y la mayoría de sus animaciones, sonidos e imágenes son del grupo dueño del juego, así que no se puede correr como antes y este sistema no se puede mostrar en video.

Resumen. Dos de los seis mapas de Sword Masters son cacerías de zombies. Mientras corre la ronda, ZombieProvider saca oleadas de enemigos elegidos por rareza entre cuatro tipos por mapa, y cada uno persigue al jugador más cercano, golpea cuando se acerca y muere con una animación o como ragdoll. El movimiento usa SimplePath, un módulo pequeño de código abierto de V3N0M_Z que envuelve el PathfindingService de Roblox. SimplePath no lo escribí yo. Lo que escribí es el zombie que va encima. Un camino calculado hacia un jugador queda viejo en cuanto el jugador se mueve, así que en vez de recorrer un camino entero, cada zombie camina saltos de a lo sumo 12 studs y calcula un camino nuevo al terminar cada uno. Un watchdog en Heartbeat vuelve a lanzar el camino si pasa un segundo sin ningún evento, así que toda falla que SimplePath reporta en silencio, o que ni siquiera reporta, termina en un reintento. El artículo recorre la regla de los saltos y lo que cuesta, el watchdog y por qué un segundo funciona con estas velocidades, la elección del objetivo, la cabeza que sigue al jugador, los ataques, las oleadas y los restos que arreglaría.

1. El problema

PathfindingService calcula un camino hacia un punto fijo. Un jugador no es un punto fijo: corre, salta y hace dash, y un camino hacia donde estaba hace tres segundos no sirve. Recalcular en cada frame lo resuelve, pero cuesta un ComputeAsync por zombie por frame, y una cacería puede tener decenas de zombies vivos a la vez. En el medio hay toda una familia de estrategias, y cada una elige cada cuánto volver a planear [5]. Yo quería algo que costara poco, que siguiera al jugador lo bastante bien para un zombie (se supone que son torpes) y, sobre todo, que no pudiera quedarse trabado para siempre, porque un zombie trabado en una ronda con meta de muertes bloquea el objetivo.

2. SimplePath

SimplePath [1] va encima de PathfindingService [2] y le da a un NPC un objeto Path. Path:Run(objetivo) calcula un camino desde la PrimaryPart del modelo hasta el objetivo con ComputeAsync y lo recorre llamando a Humanoid:MoveTo en cada waypoint, encadenándolos con MoveToFinished. Expone cuatro eventos:

  • Reached, cuando el agente llega al último waypoint;
  • WaypointReached, en cada waypoint intermedio;
  • Blocked, cuando un obstáculo tapa el camino por delante del agente;
  • Error, con un tipo: LimitReached, TargetUnreachable, ComputationError o AgentStuck.

Dos de esos errores salen de reglas del propio SimplePath y no del motor. Si se llama a Run otra vez menos de 0.07 s después de la llamada anterior, no calcula nada y dispara LimitReached. Y en cada Run compara la posición del agente con la del Run anterior: si se movió 0.07 studs o menos, lo hace saltar y dispara AgentStuck. Además le pasa al servidor la propiedad de red del agente para que no tiemble, y tiene una opción para dibujar los waypoints como bolitas de neón.

3. Un zombie es un objeto

ZombieProvider.NewInfected(nombre, reacción, onAttack, timeout, debug) arma un zombie y devuelve un objeto cuya metatabla es el propio servicio, así que los métodos (Revive, Attack, Kill…) viven en un solo lugar. La construcción hace esto:

  1. Clona el modelo de ReplicatedStorage.Assets.Zombies[mapa][nombre] y lee sus estadísticas de Classes/Zombies.
  2. Clona un script Animate estándar y reemplaza sus IDs de animación por los del tipo, con las animaciones por defecto de Roblox como respaldo cuando un tipo no define alguna.
  3. Deja la raíz anclada, así que el zombie no se mueve hasta que lo reviven en su punto de aparición.
  4. Crea el Path con WaypointSpacing igual a la mitad de la distancia de reacción.
  5. Conecta los eventos del camino, un bucle en Heartbeat (watchdog y cabeza), el handler de muerte y el ataque, y mete las conexiones en un Janitor para limpiarlas juntas.

La ronda lo llama con reacción = 12, timeout = 1 y el golpe como onAttack. Así que cada salto mide a lo sumo 12 studs y tiene waypoints cada 6.

4. Perseguir a saltos

4.1 La regla de la meta

Sea h la posición del zombie, p la de su objetivo y d=‖p−h‖. Con R=12, la meta del siguiente salto es

g=h+min⁡(1,Rd)(p−h)

es decir, un punto a R studs en dirección al jugador si el jugador está más lejos que R, y la posición del jugador si está más cerca. Es el comportamiento “seek” del steering [3], cuantizado: en vez de corregir la dirección en cada frame, el zombie la corrige cada R studs.

4.2 Volver a planear al llegar

Todo el trabajo pasa en el handler de Reached. Cuando termina un salto, el zombie busca al jugador más cercano, intenta atacar si está a su alcance, calcula la siguiente meta con la regla de arriba y vuelve a llamar a Run. Blocked y AgentStuck también terminan en un Run nuevo. En la práctica, el zombie vuelve a planear cada vez que recorre unos R studs.

4.3 Lo que cuesta

Si el camino sigue una línea recta, un salto dura R/v segundos, donde v es la velocidad del zombie. Las velocidades van de 13 studs/s (el miniboss del Colosseum) a 27 (los rápidos), así que un salto dura entre 0.44 y 0.92 s, y cada zombie calcula entre

vR∈[1312, 2712]≈[1.1, 2.25]

caminos por segundo. La velocidad ponderada por la rareza de aparición en Mystery Forest es 0.45⋅19+0.30⋅27+0.20⋅16+0.05⋅14≈20.6 studs/s, unos 1.7 caminos por segundo por zombie. Con 30 zombies persiguiendo a la vez son unas 50 llamadas a ComputeAsync por segundo en el servidor, contra las 1 800 que haría falta para volver a planear en cada frame a 60 Hz.

El precio es el retraso. El zombie se entera de que el jugador cambió de dirección cuando termina su salto, como mucho R/v segundos después, que es menos de un segundo para todos los tipos. En enemigos cuyo único trabajo es caminar hacia ti y pegarte, ese retraso se lee como torpeza y no como un bug.

5. El watchdog

Un camino puede fallar sin que nadie se entere. ComputationError y TargetUnreachable disparan Error, pero el zombie no los maneja. Y si ComputeAsync falla antes de que haya camino, nada se mueve. Sin un respaldo, un zombie que cae en uno de esos casos se queda quieto hasta que termina la ronda.

El respaldo es una cuenta regresiva en Heartbeat:

self.Timeout -= deltaTime;
if self.Timeout <= 0 then
	self.Timeout = timeout
	self._TimeoutSignal:Fire();
end

Cuando llega a cero, dispara una señal que vuelve a llamar a Run hacia la meta actual y se vuelve a armar. Cada evento que demuestra que el camino está vivo (Reached, Blocked, AgentStuck, LimitReached) devuelve el contador a un segundo. Así el zombie vuelve a lanzar su camino siempre que pasa un segundo sin noticias, sea cual sea la razón.

Un segundo no es arbitrario con estas velocidades. Para que el watchdog no corte los saltos normales, un salto en línea recta tiene que terminar antes de que se acabe el contador:

Rv<τ⟺v>Rτ=12 studs/s

Todos los tipos van más rápido que 12. El más lento, a 13, termina un salto en 0.92 s, con 0.08 s de margen. Cuando el camino rodea algo y es más largo que vτ studs, el watchdog salta antes de que el zombie llegue y lo hace planear de nuevo desde donde está, lo que también es razonable. Un tipo más lento que 12 studs/s quedaría cortado en cada salto, y habría tenido que atar el contador a la velocidad. WaypointReached no reinicia el contador, así que un camino largo igual se corta al segundo aunque el zombie vaya pasando waypoints.

Los errores que sí tienen respuesta son baratos. AgentStuck marca al zombie como trabado y relanza el camino, y un Blocked que llega mientras está marcado como trabado hace lo mismo. LimitReached espera 0.333 s, bastante por encima de los 0.07 s de SimplePath, y vuelve a llamar a Run.

6. Elegir el objetivo

ClosestPlayer recorre los jugadores de la ronda y se queda con el más cercano, partiendo de una distancia máxima de 255 studs. Tal como está el código, si todos los jugadores están a más de 255 studs devuelve igual al primero de la lista, así que mientras haya alguien en la ronda, un zombie siempre tiene objetivo y siempre persigue.

Hay un resto que parece un desempate al azar:

if ((result==0) and (magnitude < dist) or (magnitude <= dist)) then

and liga más fuerte que or, así que la condición es (r=0∧di<d)∨(di≤d), y como di<d implica di≤d, la moneda r no tiene ningún efecto: la condición es solo di≤d, y en un empate gana el último jugador revisado.

7. La cabeza que te mira

En cada frame, si el zombie tiene objetivo, revisa si lo tiene delante. La prueba es el signo de la coordenada Z del objetivo en el espacio del zombie:

(h−1⋅p)Z<0⟺(p−h)⋅fˆ>0

donde fˆ es el vector hacia donde mira el zombie, porque el adelante de Roblox es −Z. Si el objetivo está delante, rota el C0 de la articulación Neck hacia él, restando el giro de la raíz, porque C0 vive en el marco del torso. Si está detrás, o no hay objetivo, la cabeza baja 30° y el zombie camina mirando el piso. Son dos líneas por frame y es lo que hace que parezca que vienen por ti.

8. Atacar

Cuando un salto termina con el objetivo dentro de AttackDistance (25 studs para todos los tipos), el zombie ataca. El ataque es una función envuelta en una promesa: pone una bandera Resting, reproduce la animación del golpe, aplica el daño 0.333 s después para que coincida con el golpe de la animación y espera el cooldown del tipo (2 s para todos) antes de resolverse. Al resolverse, la bandera se limpia. Mientras Resting está puesta, Attack vuelve de inmediato, así que hay a lo sumo un ataque en curso por zombie.

9. Las oleadas

Cuando empieza una cacería, el servicio abre una promesa con un bucle que corre hasta RoundEnded. En cada pasada, si la cuenta es a lo sumo MaxZombies del mapa, espera SpawnRate segundos y saca entre 1 y MaxSpawn zombies en puntos de aparición al azar. Cada tipo sale por ruleta [4] con peso igual a su Rarity, que suma 100 en los dos mapas:

Mapa Tipo Rareza Vida Velocidad Daño
Mystery Forest Zombie 45 25 19 8
Mystery Forest Fast Zombie 30 20 27 8
Mystery Forest Slow Zombie 20 40 16 12
Mystery Forest Crystal Guard (miniboss) 5 250 14 20
Colosseum Zombie Gladiator 45 30 20 10
Colosseum Attacker Zombie 30 40 27 18
Colosseum Armored Zombie 20 75 15 15
Colosseum Victorious Zomdiator (miniboss) 5 450 13 24

El miniboss es el 5% de las apariciones pero pesa mucho más en vida. En Mystery Forest la vida esperada por aparición es 0.45⋅25+0.30⋅20+0.20⋅40+0.05⋅250=37.75, y 12.5 de eso vienen del Crystal Guard, un tercio. En Colosseum son 22.5 de 63.

Los zombies muertos se quedan en la lista del servicio (Kill los agrega a Corpses pero no los saca de Zombies), así que MaxZombies limita el total que aparece por ronda y no los zombies vivos. El bucle se detiene cuando la cuenta pasa el límite, así que salen como mucho MaxZombies+MaxSpawn: 82 en Mystery Forest y 50 en Colosseum. Con un promedio de (1+MaxSpawn)/2 zombies por oleada, Mystery Forest llega a su límite después de unas 19 oleadas, unos 95 s dentro de una ronda de 225 s, y Colosseum después de unas 8, unos 56 s.

Cuando un zombie muere, reproduce su animación de muerte si su tipo tiene una (solo los minibosses) o se vuelve un ragdoll R6, con un módulo de la Creator Store que el repo no redistribuye. Su cuerpo pasa a la carpeta del mapa, así que desaparece junto con él, y sus conexiones y su camino se destruyen. Cuando termina la ronda, la promesa se resuelve y limpia lo que quede.

10. Limitaciones

Quedaron algunos restos de depuración y de versiones anteriores. La ronda crea los zombies con debug = true, así que SimplePath dibuja los waypoints de cada camino como bolitas de neón, creando y destruyendo partes en cada salto, en producción. El desempate al azar de la sección 6 no hace nada. Y el código para deambular cuando no hay objetivo está muerto: SetWalkingPurpose(false) asigna self.GoalPosition or self:RandomPosition(), pero la meta nunca es nil (empieza como Vector3.zero), así que el zombie conserva la meta anterior.

Varias estadísticas no hacen nada: Shield, HasShield y HasMelee están en los datos pero ningún código las lee. AttackDistance es 25 studs para todos los tipos, mucho para un golpe, y el daño llega 0.333 s después del chequeo sin volver a revisar la distancia.

El límite de MaxZombies choca con la meta. Colosseum pide 20+7(n−1) muertes y saca como mucho 50 zombies, así que desde 6 jugadores la meta no se puede alcanzar. Encima, solo cuentan las muertes con espada, porque las de las habilidades pasan por otro camino que actualiza las estadísticas y no el objetivo. Como explica el artículo de las rondas, la meta no termina la ronda, así que nadie se queda trabado, pero el panel promete algo que el mapa no puede cumplir.

11. Lo que me dejó

La regla de los saltos fue la mejor decisión de este sistema: un solo parámetro, R, controla a la vez el costo en el servidor y qué tan bien te sigue un zombie, y es fácil razonar sobre él. El watchdog fue la segunda mejor, porque convirtió “qué hago con cada error de SimplePath” en “si no pasa nada en un segundo, intenta de nuevo”, que cubre los casos que conocía y los que no. Si lo hiciera de nuevo, ataría el watchdog a la velocidad (τ=kR/v con algún margen k>1), llevaría una cuenta de zombies vivos aparte del total y borraría las estadísticas que no hacen nada en vez de dejarlas en los datos.

Referencias

  1. V3N0M_Z. Documentación de SimplePath. https://v3n0m-z.github.io/RBLX-SimplePath/
  2. Roblox Creator Documentation. “Character pathfinding”.
  3. C. W. Reynolds. “Steering Behaviors For Autonomous Characters”. Game Developers Conference, 1999.
  4. D. E. Goldberg. Genetic Algorithms in Search, Optimization, and Machine Learning. Addison-Wesley, 1989.
  5. I. Millington, J. Funge. Artificial Intelligence for Games, 2nd ed. Morgan Kaufmann, 2009.

Este texto de Mastedore. Todos los derechos reservados.