Implementation of cutscenes and sequences of actions in games
- Tutorial
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 Korutin uses, since they are not in all languages).
The article shows how to create a mechanism that allows you to write the following cutscenes:
Action sequences are often found in video games. For example, in the 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. The level changes
6. The screen brightens smoothly
7. The character enters the cafe.
Sequences of actions can also be used for scripting NPC behavior or for realizations of boss fights in which the boss performs some actions one after another.
The structure of the standard game cycle makes the implementation of sequences of actions difficult. Suppose we have the following game cycle:
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:
And here we meet the 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 function,
Let's take a look at several hikes to solve a problem.
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:
This approach easily leads to spaghetti code and long chains of if-else expressions, so I recommend avoiding this solution.
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
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 is how it is implemented
The function
And finally, the implementation of the cutscene itself:
Note : The call in Lua
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 this
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 while and was generally happy. But this approach also has disadvantages. Suppose we want to implement a slightly more complicated cutscene:
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.
Korutina is a function that can be paused and then resumed later. The korutins are executed in the same thread as the main program. New threads for corutin are never created.
To pause the queen, you need to call
The output of the program:
This is how it works. First, we create a quartz with help
If the arguments are passed in the call
For example:
If
Conclusion:
The status of the coruntine can be obtained by calling
Now, with the help of this knowledge, we can implement a system of sequences of actions and cutscenes, based on korutinah.
Here is what the base class will look like
The approach is similar to action lists: the
Finally, creating a cutscene:
Here's how the function is implemented
The creation of such wrappers significantly increases the readability of the cutsce code.
This implementation is identical to the one we used in action lists! Let's now take a look at the function again
The main thing here is a loop
Let's now look at the function
Korutin perfectly combined with the events (events). Implement the class
This function does not need a method
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.
Using korutin, you can easily create nonlinear cutscenes and dialog trees:
In this example, the function is
With the help of Korutin, you can easily create tutorials and small quests. For example:
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:
When the monster sees the player, we can simply stop doing the coruntine and remove it. Therefore, the infinite loop (
Even with the help of korutin, you can do "parallel" actions. The cutscene will proceed to the next action only after the completion of both actions. 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”.
The most important part here is the function
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 will be waiting until one of the riders reaches the finish line and then perform some sequence of actions.
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 coruntine. 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 , corutins can be serialized, but the library only works with Lua 5.1)
This problem does not occur with cutscenes, since 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 “player passed 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 begin the execution of the coroutine, the corresponding part, which the player must pass.
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.
The article code is written in Lua, but can easily be written in other languages (with the exception of the method that Korutin uses, since they are not in all languages).
The article shows how to create a mechanism that allows you to write the following cutscenes:
localfunctioncutscene(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")
endend
Introduction
Action sequences are often found in video games. For example, in the 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. The level changes
6. The screen brightens smoothly
7. The character enters the cafe.
Sequences of actions can also be used for scripting NPC behavior or for realizations of 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 meet the 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 function,
delay
the same cannot be called 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:
functionupdate(dt)if cutsceneState == 'playerGoingToNpc'then
player:continueGoingTo(npc)
if player:closeTo(npc) then
cutsceneState = 'npcSayingYouDidIt'
dialogueWindow:show("You did it!")
endelseif cutsceneState == 'npcSayingYouDidIt'thenif dialogueWindow:wasClosed() then
cutsceneState = 'delay'endelseif ...
... -- и так далее...endend
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
update
, a 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 is how it is implemented
DelayAction
:-- конструкторfunctionDelayAction:initialize(params)
self.delay = params.delay
self.currentTime = 0
self.isFinished = falseendfunctionDelayAction:update(dt)
self.currentTime = self.currentTime + dt
if self.currentTime > self.delay then
self.isFinished = trueendend
The function
ActionList:update
looks like this:functionActionList:update(dt)ifnot self.isFinished then
self.currentAction:update(dt)
if self.currentAction.isFinished then
self:goToNextAction()
ifnot self.currentAction then
self.isFinished = trueendendendend
And 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 : The call in Lua
someFunction({ ... })
can be done like this: someFunction{...}
. This allows you to write DelayAction:new{ delay = 0.5 }
instead 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 this
DelayAction
to make writing cutscenes more convenient. 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 while and was generally happy. But this approach also has disadvantages. Suppose we want to implement a slightly more complicated cutscene:
localfunctioncutscene(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")
endend
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 korutins are executed in the same thread as the main program. New threads for corutin are never created.
To pause the queen, you need to call
coroutine.yield
to resume coroutine.resume
. A simple example:localfunctionf()print("hello")
coroutine.yield()
print("world!")
endlocal 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 a quartz with help
coroutine.create
. After this call, the korutin does not start executing. For this to happen, we need to run it with coroutine.resume
. Then a function is called f
that writes “hello” and pauses itself with help coroutine.yield
. This is similar to return
, but we can resume execution f
with coroutine.resume
. If the arguments are passed in the call
coroutine.yield
, they will become the return values of the corresponding call coroutine.resume
in the “main thread”. For example:
localfunctionf()
...
coroutine.yield(42, "some text")
...
end
ok, num, text = coroutine.resume(c)
print(num, text) -- will print '42 "some text"'
ok
- a variable that allows us to find out the status of cortina. If ok
it matters true
, then everything is fine with korutina, there were no errors inside. The return values num
that follow it ( , text
) are the very arguments we passed to yield
. If
ok
it matters false
, then something went wrong with Corutina, for example, a function was called inside it error
. In this case, the second return value will be an error message. An example of a korutina in which an error occurs:localfunctionf()print(1 + notDefined)
end
c = coroutine.create(f)
ok, msg = coroutine.resume(c)
ifnot ok thenprint("Coroutine failed!", msg)
end
Conclusion:
Coroutine failed! input: 4: attempt to perform value global (global 'notDefined')
The status of the coruntine can be obtained by calling
coroutine.status
. Korutina can be in the following states:- “Running” - korutina is running at the moment.
coroutine.status
was called from the korutiny itself - "Suspended" - the quorutine was paused or never launched
- “Normal” - korutina is active, but not executed. That is, Korutina launched another Korutina within herself.
- “Dead” - corutin completed execution (i.e., the function inside corutina completed)
Now, with the help of this knowledge, we can implement a system of sequences of actions and cutscenes, based on korutinah.
Creating a cutscene using corutin
Here is what the base class will look like
Action
in the new system:functionAction:launch()
self:init()
whilenot self.finished dolocal dt = coroutine.yield()
self:update(dt)
end
self:exit()
end
The approach is similar to action lists: the
update
action function is called until the action has completed. But here we use korutiny and do yield
in each iteration of the game cycle ( Action:launch
called from some korutiny). Somewhere in the update
game cycle, we resume the execution of the current cutscete like this:coroutine.resume(c, dt)
Finally, creating a cutscene:
functioncutscene(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 function is implemented
delay
:functiondelay(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 - базовый класс DelayActionlocal DelayAction = class("DelayAction", Action)
functionDelayAction:initialize(params)
self.delay = params.delay
self.currentTime = 0
self.isFinished = falseendfunctionDelayAction:update(dt)
self.currentTime = self.currentTime + dt
if self.currentTime >= self.delayTime then
self.finished = trueendend
This implementation is identical to the one we used in action lists! Let's now take a look at the function again
Action:launch
:functionAction:launch()
self:init()
whilenot self.finished dolocal dt = coroutine.yield() -- the most important part
self:update(dt)
end
self:exit()
end
The main thing here is a loop
while
that runs until the action is complete. It looks like this: Let's now look at the function
goTo
:functionEntity:goTo(target)local action = GoToAction:new { entity = self, target = target }
action:launch()
endfunctionGoToAction:initialize(params)
...
endfunctionGoToAction:update(dt)ifnot self.entity:closeTo(self.target) then
... -- логика перемещения, AIelse
self.finished = trueendend
Korutin perfectly combined with the events (events). Implement the class
WaitForEventAction
:functionWaitForEventAction:initialize(params)
self.finished = false
eventManager:subscribe {
listener = self,
eventType = params.eventType,
callback = WaitForEventAction.onEvent
}
endfunctionWaitForEventAction:onEvent(event)
self.finished = trueend
This function does not need a method
update
. 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 function say
:functionEntity: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.
Using korutin, 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 function is
say
slightly more complex than the one I showed earlier. It returns the choice of the player in the dialogue, but it is not difficult to implement. For example, inside can be used WaitForEventAction
, which catches the event PlayerChoiceEvent
and then returns the choice of the player, 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:
functionfollowPath(monster, path)local numberOfPoints = path:getNumberOfPoints()
local i = 0-- индекс текущей точки в путиwhiletruedo
monster:goTo(path:getPoint(i))
if i < numberOfPoints - 1then
i = i + 1-- перейти к следующей точкеelse-- начать сначала
i = 0endendend
When the monster sees the player, we can simply stop doing the coruntine and remove it. Therefore, the infinite loop (
while true
) inside followPath
is not really infinite. Even with the help of korutin, you can do "parallel" actions. The cutscene will proceed to the next action only after the completion of both actions. 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”.
functioncutscene(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 function
waitForFinish
that is a wrapper around the class WaitForFinishAction
, which can be implemented as follows:functionWaitForFinishAction:update(dt)if coroutine.status(self.c1) == 'dead'and
coroutine.status(self.c2) == 'dead'then
self.finished = trueelseif coroutine.status(self.c1) ~= 'dead'then
coroutine.resume(self.c1, dt)
endif coroutine.status(self.c2) ~= 'dead'then
coroutine.resume(self.c2, dt)
endend
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 will be waiting until one of the riders reaches the finish line and then perform some 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 coruntine. 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 , corutins can be serialized, but the library only works with Lua 5.1)
This problem does not occur with cutscenes, since 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 “player passed 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 begin the execution of the coroutine, 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.