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:

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, delaythe 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:updatelooks 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 DelayActionto 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.yieldto 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 fthat writes “hello” and pauses itself with help coroutine.yield. This is similar to return, but we can resume execution fwith coroutine.resume.

If the arguments are passed in the call coroutine.yield, they will become the return values ​​of the corresponding call coroutine.resumein 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 okit matters true, then everything is fine with korutina, there were no errors inside. The return values numthat follow it ( , text) are the very arguments we passed to yield.

If okit 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.statuswas 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 Actionin 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 updateaction function is called until the action has completed. But here we use korutiny and do yieldin each iteration of the game cycle ( Action:launchcalled from some korutiny). Somewhere in the updategame 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. DelayActionimplemented 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 whilethat 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 sayslightly 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 PlayerChoiceEventand 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 followPathis 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 waitForFinishthat 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.

Also popular now: