< Volver 

Sword Masters: Jefe

2024-09-15

La pelea contra el Mountain Guardian venía de la versión anterior de Sword Masters, y mi trabajo fue integrarla. Decide con varios bucles en temporizadores independientes, un conjunto de banderas en atributos y cooldowns que cuentan hacia atrás, y elige sus ataques como una cadena de Markov que nunca repite un movimiento dos veces seguidas.

LuauRobloxGameplayAI
Ir a vista rápida

Un jefe que decide con temporizadores y banderas.

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. Mountain Top, uno de los seis mapas de Sword Masters, es una pelea contra el Mountain Guardian, un jefe que patea el suelo, dispara láseres giratorios desde una plataforma y llena la arena de pinchos. Ese código no lo escribí yo. Venía de la versión anterior del juego, escrito por otro desarrollador, y lo que hice fue integrarlo a la reescritura: lo envolví en un servicio de Knit, lo conecté al ciclo de rondas, lo apunté a la carpeta nueva del mapa y lo marqué como obsoleto para que se reescribiera. Para integrarlo primero tuve que entender cómo decide qué hacer, y la respuesta es que no es una sola máquina de estados. Son varios bucles que corren en paralelo, cada uno con su propio temporizador, leyendo y escribiendo banderas guardadas como atributos en el modelo del jefe, con cooldowns que cuentan hacia atrás segundo a segundo. Este artículo describe ese diseño tal como lo encontré: los bucles, los estados que implican, la elección de movimientos como cadena de Markov, el descanso después de tres ataques, una semántica rara en los cooldowns y lo que la integración dejó a medias.

1. Qué era mío y qué no

El jefe vive en Server/Providers/BossProviderDeprecated, y el nombre es a propósito. Mi parte fue esta:

  • BossFight pasó a ser un servicio de Knit, así que arranca con el resto de los providers.
  • La ronda lo hace aparecer. RoundService.ModeBegin.Bossfight llama a bossfight.new():SpawnBoss() cuando la ronda empieza en Mountain Top.
  • El código de los ataques busca el mapa en workspace.GameMap.Map, la carpeta que usa la ronda nueva.
  • KnitStart imprime en cada arranque un aviso de que el módulo está obsoleto y hay que reescribirlo lo antes posible.

El comportamiento, es decir, qué hace el jefe y cuándo, no es mío, y no lo cambié. Todo lo que sigue es un análisis de código heredado.

2. La aparición

SpawnBoss escala la vida con el número de jugadores en la ronda n:

HP(n)=1000+425(n−1)

de 1 000 en solitario a 4 825 con diez jugadores. Desactiva el estado Dead del Humanoid, para que el jefe no se desarme cuando su vida llega a cero, y maneja la muerte por su cuenta cuando HealthChanged reporta 0 o menos: congela al jefe, lo marca como muerto y reproduce la animación de muerte. Esconde las partes del modelo durante 0.8 s mientras el jefe aparece, reproduce una animación de entrada cuyo marcador Landing levanta una nube de partículas, y les manda la barra de vida a todos los jugadores de la ronda.

Cuando termina la animación de entrada, SpawnBoss activa un script dentro del propio modelo del jefe, que revisa que el modo del mapa sea Bossfight y arranca los bucles.

3. Cómo decide

3.1 Banderas en el modelo

El estado del jefe no es una variable. Son seis atributos en el modelo: State (un texto como "Idle", "Attacking" o "Break"), IsAttacking, InBreak, RandomAction, IsDead y GetDamage. Un módulo auxiliar, BossBody, envuelve las lecturas y escrituras (IsAttacking(), ChangeBreakStatus(true)…). Cada bucle lee las banderas que le importan y escribe las de la acción que empieza. Es una pizarra: un espacio compartido que varios procesos independientes leen y escriben.

3.2 Cinco bucles

Bucle Período Qué hace
Persecución 0.3 s si no está atacando, descansando ni haciendo una acción, camina hacia el jugador más cercano
Árboles 0.2 s lanza todo árbol que esté a menos de 66 studs
Ataques al azar, de 7 a 10 s espera a que terminen la acción actual y los cooldowns, y ataca
Acciones 6 s si está quieto, sortea entre el MEGAKICK y no hacer nada
Pierna evento cuando un jugador entra a la zona de la pierna, lo patea lejos

La zona de la pierna usa ZonePlus [4], un módulo de terceros que dispara eventos cuando los jugadores entran o salen de un volumen. El MEGAKICK, que lanza todos los árboles del mapa a la vez, pasa una sola vez por pelea.

3.3 Los estados que nadie escribió

A partir de las banderas y los bucles se pueden reconstruir los estados por los que pasa el jefe:

Estado implícito Banderas
Persiguiendo ninguna puesta
Atacando con el movimiento m IsAttacking, State = "Attacking"
Haciendo una acción RandomAction, State = "Attacking"
Descansando InBreak, State = "Break"
Muerto IsDead

Una máquina de estados tendría esos cinco estados y una tabla de transiciones [1]. Aquí las transiciones son los momentos en que un bucle encuentra las banderas en la combinación correcta y las cambia, y nada garantiza que dos bucles no las encuentren así en el mismo instante. Las guardas (if not IsAttacking() and not IsInBreak() ...) intentan evitarlo, pero están repetidas con pequeñas variaciones en cada bucle, y no siempre son las mismas. Un árbol de comportamiento [2] o una máquina explícita habría puesto en un solo lugar la prioridad entre atacar, actuar y descansar.

4. Elegir un movimiento

El bucle de ataques llama a Attack(), que sortea uno de tres movimientos de forma uniforme y, si es igual al anterior, vuelve a sortear hasta que no lo sea:

local RandomNumber = math.random(1,3)
if self.OldMove == RandomNumber then
	repeat
		RandomNumber = math.random(1,3)
	until self.OldMove ~= RandomNumber
end

Eso es una cadena de Markov [3] sobre los tres movimientos, con matriz de transición

P=(012121201212120)

La matriz es doblemente estocástica (sus columnas también suman uno), así que su distribución estacionaria es uniforme: a la larga, cada movimiento ocupa un tercio de los ataques. Lo que cambia frente a un sorteo sin memoria son las secuencias. Un movimiento nunca se repite dos veces seguidas, y en cada ciclo de tres ataques el tercero es igual al primero con probabilidad 1/2, así que la mitad de los ciclos muestra los tres movimientos y la otra mitad muestra un patrón A, B, A. El bucle de repetición necesita en promedio 1+13⋅32=1.5 sorteos por elección.

Los tres movimientos:

  • En la patada al suelo, el jefe se queda quieto y patea el suelo de una a tres veces. Cada patada, en un marcador de la animación, lanza una onda expansiva que crece hasta 1200×0.1×1200 studs en 5 s.
  • Para los láseres, el jefe vuelve caminando a su plataforma y queda 40 studs por encima de ella, la plataforma se transforma, y dos láseres crecen de ella y giran durante unos 15 s. La velocidad de giro empieza en 0.02 radianes por frame y sube 0.0014 cada segundo, que a 60 Hz es más o menos 1.2 rad/s al principio y 2.4 al final.
  • Para los pinchos, el jefe marca desde su plataforma entre 425 y 475 puntos al azar en la arena (ZonePlus elige un punto dentro del volumen de la arena y un rayo hacia abajo encuentra el piso), y después salen pinchos de todos a la vez. Cada pincho hace 30 de daño por toque, como mucho una vez por segundo por jugador.

Dos números se comportan distinto de lo que sugiere la configuración. La cantidad de patadas al suelo, NumberOfAttackGroundKick = math.random(1,3), se evalúa una sola vez, cuando carga el módulo de configuración, así que todas las patadas de ese servidor usan el mismo número durante toda su vida. Lo mismo pasa con la cantidad de pinchos. Y los pinchos se crean con task.wait(0.001) entre uno y otro, pero task.wait no puede esperar menos de un frame, así que poner 450 pinchos tarda unos 450 frames, 7.5 segundos a 60 Hz, y no los 0.45 que sugiere el número.

5. El descanso

Después de tres ataques, el jefe descansa. Espera al cooldown de ataque, marca InBreak, vuelve caminando a su plataforma, reproduce en bucle la animación BreakIn y se queda así 10 segundos. Durante el descanso pone en 2 un valor DamageMultiplier en ServerStorage, con la idea de que los jugadores peguen el doble, aunque nada en el repo lee ese valor. Después reproduce BreakOut, empuja a los jugadores cercanos, vuelve a su velocidad normal y arranca un cooldown de descanso de 5 segundos.

Hay un error aritmético ahí. Para quitar un bloque del camino durante el descanso, el código multiplica su posición por un vector:

block.Position = block.Position * Vector3.new(0, -30, 0)

En Luau, el producto de dos Vector3 es componente a componente, así que el bloque no baja 30 studs: va a (0, −30y, 0), en algún lugar muy por debajo del origen. Funciona porque la intención era quitarlo del medio y termina muy lejos, y tres segundos después del descanso vuelve a su posición guardada.

6. Cooldowns que no vuelven

Cada cooldown es un número c que empieza en 5. StartCoolDown(nombre, v) lo pone en v y le resta uno por segundo hasta cero. Hay dos predicados:

IsFinished(c)⟺c≤0,IsRunning(c)⟺c<5

Antes del primer uso, c=5: no está corriendo y tampoco terminó. Durante la cuenta, está corriendo. Y después, c=0 y se queda en 0, así que está terminado y, al mismo tiempo, sigue corriendo, para siempre. Los bucles que esperan con “repetir hasta que termine” funcionan. El que pregunta “¿está corriendo?” no: el bucle de acciones solo actúa si el cooldown de descanso no está corriendo, así que después del primer descanso el jefe nunca vuelve a sortear una acción. Como el MEGAKICK pasa una sola vez y la otra opción es no hacer nada, el efecto en el juego es pequeño, pero es el tipo de semántica que rompe a la próxima persona que agregue una acción.

7. Lo que la integración dejó a medias

El problema más serio está en BossBody.IsDied:

function Boss:IsDied()
	if workspace.GameMap:FindFirstChild("MapRound") then
		...
	else
		return true
	end
end

MapRound es el nombre que usaba la versión anterior para el mapa de la ronda. La ronda nueva clona el mapa con el nombre Map, y nada en el código crea un MapRound dentro de GameMap. A menos que el place tenga uno puesto a mano, IsDied() siempre devuelve true, y como cada bucle empieza con if IsDied() then break end, todos salen en su primera vuelta: el jefe aparece, muestra su barra de vida y se queda quieto. Adapté el código de los ataques a la carpeta nueva y se me pasó esta función, que es la que todos los bucles preguntan primero.

El jefe tampoco cierra la ronda al morir (la llamada que lo hacía está comentada) y no paga nada: la función de recompensa del modo está vacía y las monedas por un jefe son 0. La persecución busca al jugador más cercano en todo el servidor, incluidos los que están en el lobby. Y el módulo usa variables globales, _G.Pause, _G.Time y _G.partspeed, que cualquier script del servidor puede pisar.

8. Lo que me dejó

La lección más útil fue sobre heredar código. Integré el jefe arreglando lo que fallaba en la primera corrida, y la función que no fallaba pero devolvía lo incorrecto se me escapó. Ahora, antes de conectar código heredado, busco cada lectura de estado global (rutas en el workspace, _G, atributos) y reviso cada una contra la estructura nueva. En cuanto al diseño, un comportamiento que emerge de temporizadores y banderas es difícil de razonar incluso con el código delante: necesité dos tablas para describirlo. Si lo reescribiera, sería una sola máquina con los cinco estados de la sección 3.3, un solo planificador que decide qué hacer después, cooldowns guardados como marcas de tiempo (os.clock() >= readyAt) y el mismo sorteo sin repetición, que es la parte que dejaría tal cual.

Referencias

  1. I. Millington, J. Funge. Artificial Intelligence for Games, 2nd ed. Morgan Kaufmann, 2009.
  2. D. Isla. “Handling Complexity in the Halo 2 AI”. Game Developers Conference, 2005.
  3. J. R. Norris. Markov Chains. Cambridge University Press, 1997.
  4. ForeverHD. ZonePlus. https://github.com/1ForeverHD/ZonePlus

Este texto de Mastedore. Todos los derechos reservados.