En esta publicación hablaré sobre cómo puedes implementar secuencias de acciones y escenas en videojuegos. Este artículo es una traducción de este artículo y sobre el mismo tema hice una presentación en Lua en Moscú, así que si prefieres ver el video, puedes verlo aquí .
El código del artículo está escrito en Lua, pero puede escribirse fácilmente en otros idiomas (con la excepción del método que usa corutinas, porque no están en todos los idiomas).
El artículo muestra cómo crear un mecanismo que le permita escribir escenas de la siguiente forma:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
Entrada
Las secuencias de acción a menudo se encuentran en los videojuegos. Por ejemplo, en escenas: el personaje se encuentra con el enemigo, le dice algo, el enemigo responde, etc. La secuencia de acciones se puede encontrar en el juego. Echa un vistazo a este gif:

1. La puerta se abre
2. El personaje entra a la casa.
3. La puerta se cierra.
4. La pantalla se oscurece gradualmente
5. El nivel cambia
6. La pantalla se desvanece brillantemente
7. El personaje entra al café.
Las secuencias de acciones también se pueden usar para escribir el comportamiento de los NPC o para implementar batallas de jefes en las que el jefe realiza algunas acciones una tras otra.
El problema
La estructura de un bucle de juego estándar dificulta la implementación de secuencias de acción. Digamos que tenemos el siguiente ciclo de juego:

while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end
Queremos implementar la siguiente escena: el jugador se acerca al NPC, el NPC dice: "¡Lo hiciste!", Y luego, después de una breve pausa, dice: "¡Gracias!". En un mundo ideal, lo escribiríamos así:
player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you")
Y aquí nos enfrentamos con un problema. Lleva algún tiempo completar los pasos. Algunas acciones pueden incluso esperar la entrada del jugador (por ejemplo, para cerrar el cuadro de diálogo). En lugar de la función de
delay
, no puede llamar al mismo modo de
sleep
: parecerá que el juego está congelado.
Echemos un vistazo a algunos enfoques para resolver el problema.
bool, enum, máquinas de estado
La forma más obvia de implementar una secuencia de acciones es almacenar información sobre el estado actual en bools, líneas o enumeraciones. El código se verá así:
function update(dt) if cutsceneState == 'playerGoingToNpc' then player:continueGoingTo(npc) if player:closeTo(npc) then cutsceneState = 'npcSayingYouDidIt' dialogueWindow:show("You did it!") end elseif cutsceneState == 'npcSayingYouDidIt' then if dialogueWindow:wasClosed() then cutsceneState = 'delay' end elseif ... ...
Este enfoque conduce fácilmente a código de espagueti y largas cadenas de expresiones if-else, por lo que recomiendo evitar este método para resolver el problema.
Lista de acciones
Las listas de acciones son muy similares a las máquinas de estado. Una lista de acciones es una lista de acciones que se realizan una tras otra. En el ciclo del juego, se llama a la función de
update
para la acción actual, que nos permite procesar la entrada y renderizar el juego, incluso si la acción lleva mucho tiempo. Una vez completada la acción, procedemos a la siguiente.
En la escena que queremos implementar, necesitamos implementar las siguientes acciones: GoToAction, DialogueAction y DelayAction.
Para más ejemplos,
usaré la biblioteca de
clase media para OOP en Lua.
Así es como
DelayAction
implementa una
DelayAction
:
La función
ActionList:update
ve así:
function ActionList:update(dt) if not self.isFinished then self.currentAction:update(dt) if self.currentAction.isFinished then self:goToNextAction() if not self.currentAction then self.isFinished = true end end end end
Y finalmente, la implementación de la propia escena:
function makeCutsceneActionList(player, npc) return ActionList:new { GoToAction:new { entity = player, target = npc }, SayAction:new { entity = npc, text = "You did it!" }, DelayAction:new { delay = 0.5 }, SayAction:new { entity = npc, text = "Thank you" } } end
Nota : en Lua, una llamada a
someFunction({ ... })
se puede hacer así:
someFunction{...}
. Esto le permite escribir
DelayAction:new{ delay = 0.5 }
lugar de
DelayAction:new({delay = 0.5})
.
Se ve mucho mejor. El código muestra claramente la secuencia de acciones. Si queremos agregar una nueva acción, podemos hacerlo fácilmente. Es bastante simple crear clases como
DelayAction
para que la escritura de escenas sea más conveniente.
Le aconsejo que vea la presentación de Sean Middleditch sobre listas de acciones, que proporciona ejemplos más complejos.
Las listas de acciones son generalmente muy útiles. Los usé para mis juegos durante bastante tiempo y, en general, estuve feliz. Pero este enfoque también tiene inconvenientes. Digamos que queremos implementar una escena un poco más compleja:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
Para hacer una simulación if / else, debe implementar listas no lineales. Esto se puede hacer usando etiquetas. Algunas acciones se pueden etiquetar, y luego, por alguna condición, en lugar de pasar a la siguiente acción, puede ir a una acción que tenga la etiqueta deseada. Funciona, sin embargo, no es tan fácil de leer y escribir como la función anterior.
Las rutinas de Lua hacen que este código sea una realidad.
Corutinas
Fundamentos de Corua en Lua
Corutin es una función que se puede pausar y luego reanudar. Las rutinas se ejecutan en el mismo hilo que el programa principal. Nunca se crean nuevos hilos para la rutina.
Para pausar
coroutine.yield
, debe llamar a
coroutine.yield
, para reanudar -
coroutine.resume
. Un simple ejemplo:
local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c)
Salida del programa:
hola
uhh ...
mundo
Así es como funciona. Primero, creamos
coroutine.create
usando
coroutine.create
. Después de esta llamada, la corutina no comienza. Para que esto suceda, debemos ejecutarlo usando
coroutine.resume
. Luego se llama a la función
f
, que escribe "hola" y se detiene con
coroutine.yield
. Esto es similar a
return
, pero podemos reanudar
f
con
coroutine.resume
.
Si pasa argumentos al llamar a
coroutine.yield
, se convertirán en los valores de retorno de la llamada correspondiente a
coroutine.resume
en el "flujo principal".
Por ejemplo:
local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text)
ok
es una variable que nos permite conocer el estado de una corutina. Si está
ok
, entonces con la rutina todo está bien, no se han producido errores en su interior. Los valores de retorno que le siguen (
num
,
text
) son los mismos argumentos que pasamos para
yield
.
Si
ok
es
false
, entonces algo salió mal con la rutina, por ejemplo, la función de
error
se llamó dentro de ella. En este caso, el segundo valor de retorno será un mensaje de error. Un ejemplo de una rutina en la que se produce un error:
local function f() print(1 + notDefined) end c = coroutine.create(f) ok, msg = coroutine.resume(c) if not ok then print("Coroutine failed!", msg) end
Conclusión
¡La rutina falló! input: 4: intento de realizar aritmética en un valor nulo (global 'no definido')
El estado de la rutina se puede obtener llamando a
coroutine.status
. Corutin puede estar en las siguientes condiciones:
- “En ejecución”: Coroutine se está ejecutando actualmente.
coroutine.status
se llamó desde la propia corutina - "Suspendido": Corutin se detuvo o nunca se inició
- "Normal": la corutina está activa, pero no se ejecuta. Es decir, la corutina lanzó otra corutina dentro de sí misma.
- "Muerto": ejecución completa de la rutina (es decir, la función dentro de la rutina completada)
Ahora, con la ayuda de este conocimiento, podemos implementar un sistema de secuencias de acciones y escenas basadas en rutinas.
Crear escenas con corutina
Así se verá la clase de
Action
base en el nuevo sistema:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end
El enfoque es similar a las listas de acciones: se llama a la función de
update
de la acción hasta que la acción se haya completado. Pero aquí usamos corutinas y rendimos en cada iteración del ciclo del juego (
Action:launch
se llama desde alguna corutina). En algún lugar de la
update
bucle
update
juego, reanudamos la ejecución de la escena actual como esta:
coroutine.resume(c, dt)
Y finalmente, creando una escena:
function cutscene(player, npc) player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") end
Así es como se implementa la función de
delay
:
function delay(time) action = DelayAction:new { delay = time } action:launch() end
La creación de estos contenedores mejora en gran medida la legibilidad del código de la escena.
DelayAction
implementa así:
¡Esta implementación es idéntica a la que usamos en las listas de acciones! Echemos un vistazo a la
Action:launch
función nuevamente:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield()
Lo principal aquí es el
while
, que se ejecuta hasta que se completa la acción. Se parece a esto:

Veamos ahora la función
goTo
:
function Entity:goTo(target) local action = GoToAction:new { entity = self, target = target } action:launch() end function GoToAction:initialize(params) ... end function GoToAction:update(dt) if not self.entity:closeTo(self.target) then ...
Las corutinas van bien con los eventos. Implemente la clase
WaitForEventAction
:
function WaitForEventAction:initialize(params) self.finished = false eventManager:subscribe { listener = self, eventType = params.eventType, callback = WaitForEventAction.onEvent } end function WaitForEventAction:onEvent(event) self.finished = true end
Esta función no necesita el método de
update
. Se ejecutará (aunque no hará nada ...) hasta que reciba un evento con el tipo requerido. Aquí está la aplicación práctica de esta clase: la implementación de la función
say
:
function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end
Simple y legible. Cuando se cierra el cuadro de diálogo, envía un evento de tipo 'DialogueWindowClosed`. La acción say finaliza y la siguiente comienza a ejecutarse.
Usando coroutine, puede crear fácilmente escenas de corte no lineales y árboles de diálogo:
local answer = girl:say('do_you_love_lua', { 'YES', 'NO' }) if answer == 'YES' then girl:setMood('happy') girl:say('happy_response') else girl:setMood('angry') girl:say('angry_response') end

En este ejemplo, la función
say
es un poco más compleja que la que mostré anteriormente. Devuelve la elección del jugador en el diálogo, sin embargo, no es difícil de implementar. Por ejemplo,
WaitForEventAction
se puede usar
WaitForEventAction
, lo que detectará el evento PlayerChoiceEvent y luego devolverá la elección del jugador cuya información estará contenida en el objeto del evento.
Ejemplos ligeramente más complejos
Con coroutine, puedes crear fácilmente tutoriales y pequeñas misiones. Por ejemplo:
girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!")

Las corutinas también se pueden usar para la IA. Por ejemplo, puedes hacer una función con la cual el monstruo se moverá a lo largo de alguna trayectoria:
function followPath(monster, path) local numberOfPoints = path:getNumberOfPoints() local i = 0

Cuando el monstruo ve al jugador, simplemente podemos dejar de cumplir con la rutina y eliminarla. Por lo tanto, un bucle infinito (
while true
) dentro de
followPath
no es realmente infinito.
Con la corutina, puede realizar acciones "paralelas". La escena no pasará a la siguiente acción hasta que se completen ambas acciones. Por ejemplo, haremos una escena donde una niña y un gato van al punto de un amigo a diferentes velocidades. Después de que vienen a ella, el gato dice "miau".
function cutscene(cat, girl, meetingPoint) local c1 = coroutine.create( function() cat:goTo(meetingPoint) end) local c2 = coroutine.create( function() girl:goTo(meetingPoint) end) c1.resume() c2.resume()
La parte más importante aquí es la función
waitForFinish
, que es un contenedor alrededor de la clase
WaitForFinishAction
, que se puede implementar de la siguiente manera:
function WaitForFinishAction:update(dt) if coroutine.status(self.c1) == 'dead' and coroutine.status(self.c2) == 'dead' then self.finished = true else if coroutine.status(self.c1) ~= 'dead' then coroutine.resume(self.c1, dt) end if coroutine.status(self.c2) ~= 'dead' then coroutine.resume(self.c2, dt) end end
Puede hacer que esta clase sea más poderosa si permite la sincronización del enésimo número de acciones.
También puede hacer una clase que espere hasta que
se complete
una de las corutinas, en lugar de esperar a que
todas las corutinas completen la ejecución. Por ejemplo, se puede usar en minijuegos de carreras. Dentro de la rutina habrá una espera para que uno de los corredores llegue a la línea de meta y luego realice alguna secuencia de acciones.
Ventajas y desventajas de la corutina.
Las corutinas son un mecanismo muy útil. Utilizándolos puedes escribir escenas y código de juego que sea fácil de leer y modificar. Las escenas de este tipo pueden ser escritas fácilmente por modders o personas que no son programadores (por ejemplo, diseñadores de juegos o niveles).
Y todo esto se realiza en un hilo, por lo que no hay problema con la sincronización o la
condición de carrera .
El enfoque tiene desventajas. Por ejemplo, puede haber problemas con el guardado. Digamos que su juego tiene un largo tutorial implementado con corutina. Durante este tutorial, el jugador no podrá guardar porque Para hacer esto, necesitará guardar el estado actual de la rutina (que incluye toda su pila y los valores de las variables en su interior), de modo que al continuar cargando desde el guardado, pueda continuar el tutorial.
(
Nota : al usar la biblioteca
PlutoLibrary, las corutinas se pueden serializar, pero la biblioteca solo funciona con Lua 5.1)
Este problema no ocurre con las escenas, ya que generalmente en juegos en el medio de la escena no está permitido.
El problema con un tutorial largo se puede resolver si lo divide en pedazos pequeños. Supongamos que un jugador pasa por la primera parte de un tutorial y debe ir a otra sala para continuar el tutorial. En este punto, puedes hacer un punto de control o darle al jugador la oportunidad de ahorrar. En el guardado, escribiremos algo como "el jugador completó la parte 1 del tutorial". A continuación, el jugador pasará por la segunda parte del tutorial, para lo cual ya usaremos otra rutina. Y así sucesivamente ... Al cargar, simplemente comenzamos a ejecutar la rutina correspondiente a la parte por la que debe pasar el jugador.
Conclusión
Como puede ver, hay varios enfoques diferentes para implementar una secuencia de acciones y escenas. Me parece que el enfoque de rutina es muy poderoso y estoy feliz de compartirlo con los desarrolladores. Espero que esta solución al problema te haga la vida más fácil y te permita crear escenas épicas en tus juegos.