Siete habilidades, un contrato.
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. En Sword Masters cada jugador lleva una habilidad a la ronda y la usa con la E o con el botón en pantalla. Hay siete: Dash, un God’s Dash más fuerte para admins, Superjump, Stomp, Burger, Fireball y Laser beam. Algunas tienen efecto instantáneo, una se carga mientras mantienes la tecla y dos duran varios segundos y necesitan que el jugador apunte a la mitad. No todo el sistema es mío. La base, es decir, la capa de input del cliente, sus cooldowns y las pantallas de la tienda, venía de otro desarrollador. Yo convertí el manager del servidor en una librería que carga un módulo por habilidad, y escribí las habilidades. Este artículo describe el contrato que sigue cada habilidad, los remotes que conectan cliente y servidor, los tres tipos de flujo que salen de ahí, el estado por jugador que guarda el servidor y lo que el diseño deja sin proteger. Dash, Fireball y Laser beam tienen artículos propios.
1. El problema
Las siete habilidades no se parecen. Dash, Stomp y Burger son instantáneas: aprietas y pasa algo. Superjump se carga mientras mantienes la tecla y salta cuando la sueltas. Fireball y Laser beam duran varios segundos, reproducen una secuencia de animaciones y a la mitad necesitan algo que solo sabe el cliente, que es hacia dónde apunta el jugador y cuándo hace clic. Todas tienen que hacer su daño, su movimiento y sus efectos en el servidor, para que los demás jugadores los vean y para que el cliente no decida cuánto daño hace.
Lo que quería era que agregar una habilidad fuera escribir un archivo, sin tocar el manager ni el cliente.
2. El catálogo
| Habilidad | Rareza | Precio | Cooldown | Tipo |
|---|---|---|---|---|
| God’s Dash | Admin | — | 0 s | instantánea |
| Dash | Starter | gratis | 5 s | instantánea |
| Superjump | Common | 4 500 | 13 s | mantener y soltar |
| Stomp | Uncommon | 10 000 | 12 s | instantánea |
| Burger | Rare | 22 500 | 35 s | instantánea |
| Fireball | Epic | 35 000 | 22 s | larga, con puntería |
| Laser beam | Legendary | 67 500 | 44 s | larga, con puntería |
Los datos viven en una tabla por habilidad: imagen, cooldown, rareza, precio, los IDs de sus animaciones y sus números propios (la distancia del Dash, el alcance de Fireball, la duración del Laser beam). Todo jugador nuevo empieza con Dash equipado.
3. El contrato
3.1 Un módulo por habilidad
En el servidor, Server_Abilities_Handler/Use es una carpeta de módulos. El manager hace require de cada hijo menos Lib (las utilidades compartidas) y los guarda por nombre, así que la habilidad que se llama "Fireball" es lo que devuelva el módulo Fireball. Cada módulo exporta cuatro funciones:
local main = {}
function main.Unlock() -- ¿soltar la tecla hace algo?
return unlockable
end
function main.Load(plr, playerData, Loaded, bring, cback, kill, forceSL)
-- corre cuando el jugador aprieta la tecla
end
function main.Use(plr, playerData, Loaded, bring, cback, kill, forceSL)
-- corre cuando el cliente confirma el uso
end
function main.Cleanup(plr, playerData)
return function() --[[ liberar lo que creó la habilidad ]] end
end
return main
La firma es la misma para todas las funciones de todos los módulos, aunque alguna no use la mitad de los argumentos. Eso es lo que deja al manager despachar por nombre sin saber nada de cada habilidad, en el mismo espíritu que el patrón Command [1]: quien llama solo conoce la interfaz.
3.2 Los remotes
Seis canales conectan los dos lados:
| Remote | Tipo | Dirección | Qué significa |
|---|---|---|---|
Load |
RemoteFunction | cliente → servidor | se apretó la tecla |
Unlock |
RemoteFunction | cliente → servidor | se soltó la tecla; devuelve si eso importa |
Use |
RemoteEvent | cliente → servidor | el cliente confirma el uso |
Loaded |
RemoteEvent | servidor → cliente | listo, puedes confirmar |
Callback |
RemoteFunction | servidor → cliente | el servidor le pide algo al cliente a mitad de la habilidad |
Bring |
RemoteEvent | cliente → servidor | el cliente avisa un paso a mitad de la habilidad |
Además hay un BindableEvent del servidor, kill, que las habilidades disparan cuando matan a alguien para que la muerte aparezca en las estadísticas, y un evento Shiftlock que Laser beam usa para forzar el shift lock en el cliente.
4. Tres flujos
4.1 Instantáneas
Dash, Stomp y Burger tienen un Load de una línea:
function main.Load(plr, playerData, Loaded, ...)
Loaded:FireClient(plr, self);
return true;
end
El cliente aprieta, invoca Load, y el servidor contesta disparando Loaded con el nombre de la habilidad. Al recibirlo, el cliente revisa el cooldown, anota la hora, actualiza el espacio en pantalla y dispara Use. El efecto vive en el Use del servidor. Es un viaje de ida y vuelta solo para decir “dale”, pero le da al servidor la oportunidad de decir que no antes de que pase nada.
4.2 Mantener y soltar
Superjump usa Load para cargar. Cuando el jugador está en el suelo, el bucle del servidor mide el tiempo y para al llegar a 4 s o cuando una bandera dice que se soltó la tecla. Cuando el jugador suelta, el cliente invoca Unlock, que en Superjump devuelve true, así que el cliente dispara Use, y el Use del servidor pone la bandera. El bucle de Load la ve y salta.
La carga está cuantizada. El tiempo se redondea hacia arriba a segundos enteros y se desplaza, , y la velocidad del salto es
donde es la dirección hacia donde mira el jugador. Soltar antes del primer segundo da , y cada segundo extra suma uno. La carga completa tiene una rareza: el bucle se detiene en el primer frame en que , que siempre es una fracción de frame después de 4, así que y . Eso da cinco saltos posibles, desde 105 studs/s hacia adelante y 60 hacia arriba con un toque hasta 245 y 140 con la carga completa.
4.3 Largas
Fireball y Laser beam hacen todo dentro de Load. La función corre de principio a fin como un guion: reproducir la animación de sacar el objeto, esperar a que llegue a cierto punto, pedirle input al cliente con Callback:InvokeClient, correr el efecto, limpiar y volver. En estas dos, Unlock devuelve false, así que soltar la tecla no hace nada, y el cliente dispara Use solo cuando Load vuelve, con la habilidad ya terminada.
Eso tiene una consecuencia bonita: el cliente anota la hora de uso cuando dispara Use, así que el cooldown de las largas empieza cuando terminan y no cuando empiezan. Los 44 segundos del Laser beam se cuentan desde que se apaga el rayo, no desde que apretaste la E.
Escribir una habilidad larga como una sola función tiene una ventaja clara. El estado vive en variables locales (el proyectil, las conexiones, las pistas de animación), y el orden de las fases es el orden de las líneas. La desventaja aparece en la sección 6.
5. El estado en el servidor
El manager guarda una tabla por jugador:
playerData[plr.Name] = {
AbilityAnims = {}, -- [habilidad] = { AnimationTrack, ... }
AbilityData = {}, -- [habilidad] = espacio de trabajo de esa habilidad
AbCooldown = false,
}
Cada vez que aparece el personaje, el servidor carga las animaciones de la habilidad equipada en el Animator, con prioridad Action, en el orden en que las lista la tabla de datos. Después cada módulo las usa por índice (anims[1] es la de sacar el objeto, anims[2] la de espera, y así). Si una habilidad no define animaciones, usa las del Dash con un aviso en vez de romperse. Cuando el jugador se va, Lib:MemoryBan recorre la tabla de forma recursiva, desconectando conexiones y destruyendo instancias, para que no quede nada colgado de un jugador que ya no está.
Las habilidades comparten estado con el resto del juego mediante atributos en el personaje. Busy marca que hay una habilidad larga en curso: Fireball y Laser beam lo revisan en Load y se niegan a empezar si está puesto, así que no se pueden apilar. MoreDamage es un multiplicador de daño que sube el power-up de fuerza, y cada habilidad que hace daño multiplica por él. MoreSpeed es un factor de velocidad que Superjump y Laser beam bajan mientras corren.
En el cliente, las habilidades solo funcionan mientras la ronda corre y el jugador está en ella. Dos banderas compartidas, IsRoundRunning e IsInRound, se encienden 0.667 s después de RoundStarted y se apagan con RoundEnded o al morir. La revisión del cooldown es
con el cooldown de la habilidad, medido con os.clock() y una décima de segundo de margen.
6. Limitaciones
El servidor confía demasiado en el cliente. Los cooldowns y las banderas de la ronda solo se revisan en el cliente, y ni el Use ni el Load del servidor los revisan, así que un cliente modificado puede disparar Use todas las veces que quiera. Fireball toma la trayectoria del proyectil del cliente sin validarla (su artículo entra en detalle).
InvokeClient fue la herramienta equivocada para las habilidades largas. Si el cliente nunca contesta, porque se desconecta o porque alguien lo modificó, el hilo del servidor se queda esperando para siempre, y si el callback del cliente lanza un error, el error sube al servidor. La propia documentación de Roblox advierte sobre esto [2]. Un par de RemoteEvents con un tiempo límite hace lo mismo sin entregarle al cliente el control del servidor.
Quedó duplicación del código heredado. Los datos de las habilidades viven en dos módulos con los mismos valores, Classes/Abilities y Client_Modules/ReplicatedSettings, y el código del servidor lee el segundo. El sistema admite un solo espacio, porque la configuración de teclas solo tiene Slot1 = E. Y hay un octavo módulo, Decompile, que carga pero cuyo Use está vacío.
7. Lo que me dejó
El contrato hizo lo que le pedí: agregar Stomp o Burger fue escribir un archivo con cuatro funciones, y el manager no cambió. Escribir las habilidades largas como guiones dentro de Load fue la decisión correcta para leerlas y la equivocada para la seguridad, porque una sola función que espera al cliente durante segundos le da al cliente demasiado poder sobre el servidor. Si lo hiciera de nuevo mantendría el módulo por habilidad y la firma compartida, pero pasaría todas las revisiones (cooldown, ronda, Busy) al manager antes de llamar al módulo, y haría cada habilidad una pequeña máquina de estados movida por eventos en vez de una función que espera.
Referencias
- E. Gamma, R. Helm, R. Johnson, J. Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994.
- Roblox Creator Documentation. “Remote events and callbacks”.
Este texto de Mastedore. Todos los derechos reservados.