Trabajando en la industria del videojuego, llevo ya unos cuantos años usando sistemas de scripts empotrados en juegos para controlar las cutscenes, la IA y otros elementos varios del juego.
Menos algunas excepciones eran sistemas de script muy sencillos y específicos para ciertas operaciones o para maquinas poco potentes, así que principalmente he estado usando sistemas propios creados a medida de la tareas a realizar (bueno, también cree un interprete de Forth, pero este fue mas como un reto que otra cosa).
Sin embargo el tamaño de los juegos en los que estoy metido, y por tanto la variedad, complejidad y extension de los scripts a utilizar, ha crecido hasta tal punto que ya merecía la penar utilizar un lenguaje empotrado de uso general como Lua.
Usando Lua perdemos eficiencia y la capacidad de personalización de los scripts a medida, pero a cambio ganamos un lenguaje de programación completo, ademas de contar con muchas librerías y soporte de una comunidad.
Y el origen de este articulo viene precisamente de no poder personalizar Lua fácilmente para ejecutar varios scripts a la vez a modo de multi-tarea utilizando los wrappers en C# para Unity.
Tal vez en juegos de aventura como
Tokyo Super Night o incluso RPGs por turnos como
Curse of the Red Forest es posible tener un solo hilo de ejecución. Pero normalmente quieres tener varios: por ejemplo uno por cada IA activa, otro para cut-scenes, unos cuantos para gestionar triggers, etc..
Vamos a imaginar una situación mas concreta, una cutscene de un JRPG en la que dos personajes se mueven hasta un punto y inician una conversación cuando ambos han llegado. Podríamos escribirlo así:
function my_cutscene()
move_npc(npc1,{120,120,0})
move_npc(npc2,{120,100,0})
say(npc1, "Hola npc2!")
say(npc2, "Hola npc1!")
end
A primera vista es un código fácil de entender, ¿no? El problema es que no estamos sincronizando el movimiento de los NPCs con el dialogo para que empiece solo cuando ambos hayan llegado al punto de destino.
Simplemente no podemos bloquear la ejecución en move_npc, porque impediría ejecutarse a la siguiente instrucción (y cualquier otro script que haya activo), pero si podemos esperar a que terminen de moverse con un comando nuevo:
move_npc(npc1,{120,120,0})
move_npc(npc2,{120,100,0})
-- El script parara de ejecutarse hasta que todos los personajes se paren.
say(npc1, "Hola npc2!")
say(npc2, "Hola npc1!")
wait_move()
Este tipo de scripts funcionaria si solo tuviéramos este script en ejecución. Y aun así, el wrapper de Lua para Unity que usábamos no soportaba pausar la ejecución de la VM de Lua a mitad de un script para retomarla luego (o al menos yo no encontré como hacerlo), así que este método tampoco funcionaba.
Lo que hacíamos para ejecutar scripts con "pausa" para sincronizar era aprovechar que nuestro engine para juegos de aventura soportaba bloques de codigo Lua entre texto de diálogos, al estilo de los scripts para visual novels:
%{
move_npc(npc1,{120,120,0})
move_npc(npc2,{120,100,0})
wait_to_finish_all_moves()
}%
%{ say(npc1) }% Hola npc2!
%{ say(npc2) }% Hola npc1!
En nuestros scripts el código Lua va entre %{ }% y cada bloque se ejecuta entero, pero se pausa para mostrar el texto entre bloques en pantalla (si lo hay) y continua con el siguiente bloque. Esta solución un poco cutre, nos valía para las pocas sincronizaciones que teníamos en juegos de aventura o por turnos. Pero para
Adel necesitábamos una sincronizacion mas completa.
En principio deje aparcado como mejorar este sistema de scripts, pero un día, revisitando la página del nuevo juego de uno de los maestros del videojuego, Ron Gilbert, encontré una posible solución. En
este articulo, se explicaba como usar las co-rutinas que incluyen algunos lenguajes de programación para simular multi-tarea, al estilo de como lo hacia el mítico
SCUMM. Y aquí es cuando descubrí que Lua cuenta con
co-rutinas en su funciones principales y, por lo tanto, lo podía utilizar para simular multi-tarea dentro de los scripts. Esto no es para nada nuevo, ya
existen scripts para gestionar las tareas con subrutinas. Y investigando un poco, usar co-rutinas para juegos, incluso triple-A, parece ser algo muy común: las
saga Uncharted (con su propio lenguaje de script basado en Lisp) o
Fable II y III (usando Lua, como es nuestro caso).
Ninguna de los scripts que gestionaban de multi-tarea en Lua con co-rutinas que encontré en la red me convenció, así que decidí crear el mio propio a medida (ques lo que podéis descargar junto a un proyecto de ejemplo de Unity mas abajo).
Pero llegados a este punto, ¿que son exactamente las co-rutinas? Si programáis en Unity seguramente ya las habréis visto usadas en C#. Son métodos o funciones ejecutados paralelamente a otros programas, pero no a la vez y no con cambio automático de tarea. Así una co-rutina debe dejar paso a los demás hilos de ejecución normalmente con el un comando
yield y volverá a ejecutarse después de un tiempo establecido o cuando lo despierte otro hilo. Aunque nunca sera tan optimo cono usar los threads del sistema, usar co-rutinas nos libra de muchos de los problemas de programar en paralelo usando threads, al saber exactamente cuando vamos a pasar el control a otro hilo, y ademas nos permite tener muchísimos mas hilos de ejecución (tareas en nuestro caso) de los que permiten los thread del Kernel.
La pequeña librería que hice permite ejecutar varias tareas, pararlas y pausarlas por un tiempo o hasta que se genere una señal o simplemente por un frame (para recordar al mítico DIV, que contaba también con el mismo tipo de multi-tarea hace ya casi 20 años).
Creo que todo queda mas claro aplicándolo al ejemplo del principio:
function my_cutscene()
-- Iniciamos una tarea para mover a cada personaje
Task:start("move_mycutscene",move_npc,npc1,{120,120,0})
Task:start("move_mycutscene",move_npc,npc1,{120,120,0})
-- Mientras no ternminen, esperamos
while Task:alive("move_mycutscene") do
Task:frame()
end
-- Mostramos el dialogo
say(npc1, "Hola npc2!")
say(npc2, "Hola npc1!")
end
--Aqui iniciamos la cutscene
Task:start("cutscene",my_cutscene)
Y de paso la función say también utiliza el sistema de tareas para esperar a que se muestra un mensaje antes de continuar:
function say(npc_id,text)
-- La parte en Unity mostrara el mensaje con la cara
-- del personaje
game.say(npc_id,text)
-- Y esa parte en Unity también envía siempre a la señal "end-dialog" cuando
-- acaba de mostrar el texto, así que la esperamos
Task:wait_signal("end-dialog")
end
Y ya tenemos nuestro sistema multi-tarea para cutscenes, IA y lo que necesitemos. Este ejemplo es muy sencillo, pero imaginad que hasta la saga Uncharted ejecutaría un script parecido cada vez que un NPC acompañante se para a esperarte antes de continuar la escalada.
En el link de abajo, tenéis un ejemplo prestado del articulo que me inspiro a crear este sistema, implementado con un intérprete de Lua, mi mini-librería y unos gráficos de
opengameart.org.
Descarga
No soy un experto en Lua y es la primera vez que trabajo con este tipo de sub-rutinas, así que cualquier reporte de bugs, posibles mejoras o comentario sobre este ejemplo serán bien recibidas.