Implementación de escenas y secuencias de acciones en juegos.

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 ... ... --   ... end end 

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 :

 --  function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime > self.delay then self.isFinished = true end end 

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 -- ... -    actionList:update(dt) 

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) -- will print '42 "some 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 -- -  ... local c = coroutine.create(cutscene, player, npc) coroutine.resume(c, dt) 

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í:

 -- Action -   DelayAction local DelayAction = class("DelayAction", Action) function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime >= self.delayTime then self.finished = true end end 

¡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() -- the most important part self:update(dt) end self:exit() end 

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 ... --  , AI else self.finished = true end end 

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 --      while true do monster:goTo(path:getPoint(i)) if i < numberOfPoints - 1 then i = i + 1 --     else --   i = 0 end end end 



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() --  waitForFinish(c1, c2) --    cat:say("meow") ... end 

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.

Source: https://habr.com/ru/post/es427135/


All Articles