📜 ⬆️ ⬇️

The implementation of cutscenes and sequences of actions in games

In this post I will talk about how you can implement action sequences and cutscenes in video games. This article is a translation of this article and on the same topic I gave a talk at Lua in Moscow, so if you like watching videos more, you can watch it here .

The article code is written in Lua, but can easily be written in other languages ​​(with the exception of the method that uses korutiny, since they are not in all languages).

The article shows how to create a mechanism that allows you to write cutscenes of the following form:

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 

Introduction


Action sequences are often found in video games. For example, in a cutscene: a character meets an enemy, something tells him, the enemy answers, and so on. Sequences of actions can occur in the gameplay. Take a look at this gif:
')


1. The door opens
2. The character enters the house
3. The door closes
4. The screen darkens smoothly
5. Level changes
6. The screen brightens smoothly
7. Character enters the cafe

Sequences of actions can also be used for scripting NPC behavior or for realizing boss fights in which the boss performs some actions one after another.

Problem


The structure of the standard game cycle makes the implementation of sequences of actions difficult. Suppose we have the following game cycle:



 while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end 

We want to implement the following cutscene: the player approaches the NPC, the NPC says: “You did it!”, And then after a short pause, says: “Thank you!”. In a perfect world, we would write it like this:

 player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") 

And here we encounter a problem. Taking action takes some time. Some actions may even wait for input from the player (for example, to close the dialog box). Instead of the delay function, you cannot call the same sleep - it will look as if the game is frozen.

Let's take a look at several hikes to solve a problem.

bool, enum, state machines


The most obvious way to implement workflows is to store current state information in bool, string, or enum. The code will look something like this:

 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 

This approach easily leads to spaghetti code and long chains of if-else expressions, so I recommend avoiding this solution.

Action list


Action lists are very similar to state machines. An action list is a list of actions that are performed one after another. In the game loop, the update function is called for the current action, which allows us to process the input and render the game, even if the action has been performed for a long time. After the action is completed, we proceed to the next.

In the cutscene we want to implement, we need to implement the following actions: GoToAction, DialogueAction, and DelayAction.

For further examples, I will use the middleclass library for OOP in Lua.

Here's how DelayAction implemented:

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

The ActionList:update function looks like this:

 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 

Finally, the implementation of the cutscene itself:

 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) 

Note : in Lua, the call to someFunction({ ... }) can be made like this: someFunction{...} . This allows you to write DelayAction:new{ delay = 0.5 } instead of DelayAction:new({delay = 0.5}) .

Looks much better. The code clearly shows the sequence of actions. If we want to add a new action, we can easily do it. It's pretty easy to create classes like DelayAction to make writing cutscents easier.

I advise you to watch the presentation of Sean Middleditch (Sean Middleditch) about action lists, which are more complex examples.


Action lists are generally very useful. I used them for my games for quite a long time and was generally happy. But this approach also has disadvantages. Suppose we want to implement a slightly more complicated cutscene:

 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 

To make an if / else simulation, you need to implement non-linear lists. This can be done using tags. Some actions can be tagged with tags, and then by some condition instead of moving to the next action, you can go to an action that has the desired tag. It works, however it is not as easy to read and write as the function above.

Lua korutin make this code a reality.

Korutiny


Basics of Corutin in Lua


Korutina is a function that can be paused and then resumed later. The korutinas are executed in the same thread as the main program. New threads for corutin are never created.

To pause corutin, you need to call coroutine.yield to resume - coroutine.resume . A simple example:

 local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c) 

The output of the program:

 hello
 uhh ...
 world


This is how it works. First we create the corutin using coroutine.create . After this call, the korutin does not start executing. For this to happen, we need to run it with coroutine.resume . Then the function f is called, which writes “hello” and pauses itself using coroutine.yield . This is similar to return , but we can resume executing f with coroutine.resume .

If you pass arguments when you call coroutine.yield , then they will be the return values ​​of the corresponding call to coroutine.resume in the “main thread”.

For example:

 local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text) -- will print '42 "some text"' 

ok is a variable that allows us to find out the status of cortina. If ok is true , then with korutina everything is fine, no errors occurred inside. The return values ​​that follow it ( num , text ) are the very arguments that we passed to yield .

If ok is false , then something went wrong with Corutina, for example, the error function was called inside it. In this case, the second return value will be an error message. An example of a korutina in which an error occurs:

 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 

Conclusion:

 Coroutine failed!  input: 4: attempt to perform arithmetic on a nil value (global 'notDefined')


You can get the status of coruntine by calling coroutine.status . Korutina can be in the following states:


Now, with the help of this knowledge, we can implement a system of sequences of actions and cutscenes based on corutinas.

Creating a cutscene using corutin


Here's what the base Action class will look like in the new system:

 function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end 

The approach is similar to action lists: the update function of the action is called until the action has completed. But here we use cortuins and do a yield in each iteration of the game cycle ( Action:launch is called from some cortina). Somewhere in the update game cycle, we resume the execution of the current cutscete like this:

 coroutine.resume(c, dt) 

Finally, creating a cutscene:

 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) 

Here's how the delay function is implemented:

 function delay(time) action = DelayAction:new { delay = time } action:launch() end 

The creation of such wrappers significantly increases the readability of the cutsce code. DelayAction implemented like this:

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

This implementation is identical to the one we used in action lists! Let's now take another look at the Action:launch function:

 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 

The main thing here is the while , which runs until the action completes. It looks like this:



Let's now look at the goTo function:

 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 

Korutiny perfectly combined with the events (events). Implement the WaitForEventAction class:

 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 

This function does not need the update method. It will be executed (although it will not do anything ...) until it receives an event with the required type. Here is the practical application of this class - the implementation of the say function:

 function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end 

Simple and readable. When the dialog closes, it sends an event of type 'DialogueWindowClosed'. The “say” action is completed and its execution begins following it.

With the help of coroutine, you can easily create nonlinear cutscenes and dialog trees:

 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 



In this example, the say function is slightly more complex than the one I showed earlier. It returns the player's choice in the dialogue, but it is not difficult to implement For example, WaitForEventAction can be used WaitForEventAction , which will catch the PlayerChoiceEvent event and then return the player's choice, information about which will be contained in the event object.

Slightly more complex examples.


With the help of Korutin, you can easily create tutorials and small quests. For example:

 girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!") 



Korutiny can also be used for AI. For example, you can make a function with the help of which the monster will move along some trajectory:

 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 



When the monster sees the player, we can just stop doing the coruntine and remove it. Therefore, an infinite loop ( while true ) inside followPath is not really infinite.

Even with the help of Corutin, you can do "parallel" actions. Cutscene will proceed to the next action only after both actions are completed. For example, let's make a cutscene where a girl and a cat go to a certain point to a friend with different speeds. After they come to her, the cat says “meow”.

 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 

The most important part here is the waitForFinish function, which is a wrapper around the WaitForFinishAction class, which can be implemented as follows:

 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 

You can make this class more powerful if you allow the synchronization of the Nth number of actions.

You can also make a class that will wait for one of the quorutine to complete, instead of waiting for all of the korutins to complete execution. For example, it can be used in racing mini-games. Inside the coroutine there will be a wait until one of the riders reaches the finish line and then perform any sequence of actions.

Advantages and disadvantages of corutin


Korutiny is a very useful mechanism. Using them, you can write cutscenes and gameplay code that is easy to read and modify. This kind of cutscenes can easily be written by modders or people who are not programmers (for example, game or level designers).

And all this is done in one thread, so there are no problems with synchronization or race condition (race condition) .

The approach has flaws. For example, there may be problems with saving. For example, in your game there will be a long tutorial implemented with the help of Corutin. During this tutorial, the player will not be able to persist, because to do this, you will need to save the current state of the corortina (which includes her entire stack and the values ​​of the variables inside), so that when you continue to load from the save, you can continue with the tutorial.

( Note : with the help of the PlutoLibrary library , Cortutins can be serialized, but the library only works with Lua 5.1)

This problem does not occur with cutscenes, because usually in games it is not allowed to remain in the middle of the cutscene.

The problem with a long tutorial can be solved by breaking it into small pieces. Suppose a player passes the first part of the tutorial and must go to another room to continue the tutorial. At this point, you can make a checkpoint or give the player the opportunity to save. In the save, we will write something like “the player went through part 1 of the tutorial”. Next, the player will go through the second part of the tutorial, for which we will already use another korutin. And so on ... When loading, we will simply start the execution of the korutina, the corresponding part, which the player must pass.

Conclusion


As you can see, there are several different approaches for implementing the workflow and cutscenes. It seems to me that the approach with corortes is very powerful and I am happy to share it with the developers. I hope that this solution to the problem will make your life easier and will allow you to make you epic cutscenes in your games.

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


All Articles