Имплементация катсцен и последовательностей действий в играх

  • Tutorial
В этом посте я расскажу о том, как можно имплементировать последовательности действий и катсцены в видеоиграх. Эта статья является переводом вот этой статьи и по этой же теме я делал доклад на Lua in Moscow, так что если вам больше нравится смотреть видео, то можете посмотреть вот здесь.

Код статьи написан на Lua, но легко может быть написан на других языках (за исключением метода, который использует корутины, т.к. они есть далеко не во всех языках).

В статье показывается, как создать механизм, позволяющий писать катсцены следующего вида:

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

Вступление


Последовательности действий часто встречаются в видеоиграх. Например, в катсценах: персонаж встречает врага, что-то говорит ему, враг отвечает, и так далее. Последовательности действий могут встречаться и в геймплее. Взгляните на эту гифку:



1. Открывается дверь
2. Персонаж заходит в дом
3. Дверь закрывается
4. Экран плавно темнеет
5. Меняется уровень
6. Экран плавно светлеет
7. Персонаж заходит в кафе

Последовательности действий также могут использоваться для скриптования поведения NPC или для реализаций битв с боссами, в которых босс выполняет какие-то действия одно за другим.

Проблема


Структура стандартного игрового цикла делает имплементацию последовательностей действий непростой. Допустим, у нас есть следующий игровой цикл:



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

Мы хотим имплементировать следующую катсцену: игрок подходит к NPC, NPC говорит:«You did it!», а затем после короткой паузы говорит:«Thank you!». В идеальном мире, мы бы написали это вот так:

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

И вот тут мы и встречаемся с проблемой. Выполнение действий занимает некоторое время. Некоторые действия могут даже ожидать ввода от игрока (например, чтобы закрыть окно диалога). Вместо функции delay нельзя вызвать тот же sleep — это будет выглядеть так, будто игра зависла.

Давайте взглянем на несколько походов к решению проблемы.

bool, enum, машины состояний


Самый очевидный способ для имплементации последовательностей действий — это хранить информацию о текущем состоянии в bool'ах, строках или enum'ах. Код при этом будет выглядеть примерно так:

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

Данный подход легко приводит к спагетти-коду и длинным цепочкам if-else выражений, так что я рекомендую избегать такой способ решения проблемы.

Action list


Action list'ы очень похожи на машины состояний. Action list — это список действий, которые выполняются одно за другим. В игровом цикле для текущего действия вызывается функция update, что позволяет нам обрабатывать ввод и рендерить игру, даже если действие выполняется долгое время. После того, как действие завершено, мы переходим к выполнению следующего.

В катсцене, которую мы хотим реализовать, нам нужно имплементировать следующие действия: GoToAction, DialogueAction и DelayAction.

Для дальнейших примеров я буду использовать библиотеку middleclass для ООП в Lua.

Вот, как имплементируется 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

Функция ActionList:update выглядит так:

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

И наконец, имплементация самой катсцены:

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)

Примечание: в Lua вызов someFunction({ ... }) может быть сделан вот так: someFunction{...}. Это позволяет писать DelayAction:new{ delay = 0.5 } вместо DelayAction:new({delay = 0.5}).

Выглядит гораздо лучше. В коде явно видна последовательность действий. Если мы хотим добавить новое действие, мы легко можем это сделать. Довольно просто создавать классы подобные DelayAction, чтобы делать написание катсцен удобнее.

Советую посмотреть презентацию Шона Миддлдитча (Sean Middleditch) про action list'ы, в которой приводятся более сложные примеры.


Action list'ы в целом очень полезны. Я использовал их для своих игр довольно долгое время и в целом был счастлив. Но и этот подход имеет недостатки. Допустим, мы хотим реализовать чуть более сложную катсцену:

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

Чтобы сделать симуляцию if/else, нужно реализовать нелинейные списки. Это можно сделать с помощью тэгов. Некоторые действия могут помечаться тэгами, и затем по какому-то условию вместо перехода к следующему действию, можно перейти к действию, имеющему нужный тэг. Это работает, однако это не так легко читается и пишется, как функция выше.

Корутины Lua делают этот код реальностью.

Корутины


Основы корутин в Lua


Корутина — это функция, которую можно поставить на паузу и затем позже возобновить её выполнение. Корутины выполняются в том же потоке, как и основная программа. Новые потоки для корутин не создаются никогда.

Чтобы поставить корутину на паузу, нужно вызвать coroutine.yield, чтобы возобновить — coroutine.resume. Простой пример:

local function f()
  print("hello")
  coroutine.yield()
  print("world!")
end

local c = coroutine.create(f)
coroutine.resume(c)
print("uhh...")
coroutine.resume(c)

Вывод программы:

hello
uhh...
world


Вот, как это работает. Сначала мы создаём корутину с помощью coroutine.create. После этого вызова корутина не начинает выполняться. Чтобы это произошло, нам нужно запустить её с помощью coroutine.resume. Затем вызывается функция f, которая пишет «hello» и ставит себя на паузу с помощью coroutine.yield. Это похоже на return, но мы можем возобновить выполнение f с помощью coroutine.resume.

Если передать аргументы при вызове coroutine.yield, то они станут возвращаемыми значениями соответствующего вызова coroutine.resume в «основном потоке».

Например:

local function f()
    ...
    coroutine.yield(42, "some text")
    ...
end

ok, num, text = coroutine.resume(c)
print(num, text) -- will print '42    "some text"'

ok — переменная, которая позволяет нам узнать статус корутины. Если ok имеет значение true, то с корутиной всё хорошо, никаких ошибок внутри не произошло. Следующие за ней возвращаемые значения (num, text) — это те самые аргументы, которые мы передали в yield.

Если ok имеет значение false, то с корутиной что-то пошло не так, например внутри неё была вызвана функция 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

Вывод:

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


Состояние корутины можно получить с помощью вызова coroutine.status. Корутина может находиться в следующих состояниях:

  • «running» — корутина выполняется в данный момент. coroutine.status была вызвана из самой корутины
  • «suspended» — корутина была поставлена на паузу или ещё ни разу не запускалась
  • «normal» — корутина активна, но не выполняется. То есть корутина запустила другую корутину внутри себя
  • «dead» — корутина завершила выполнение (т.е. функция внутри корутины завершилась)

Теперь с помощью этих знаний мы можем имплементировать систему последовательностей действий и катсцен, основанную на корутинах.

Создание катсцен с помощью корутин


Вот, как будет выглядеть базовый класс Action в новой системе:

function Action:launch()
  self:init()

  while not self.finished do
    local dt = coroutine.yield()
    self:update(dt)
  end

  self:exit()
end

Подход похож на action list'ы: функция update действия вызывается до тех пор, пока действие не завершилось. Но здесь мы используем корутины и делаем yield в каждой итерации игрового цикла (Action:launch вызывается из какой-то корутины). Где-то в update игрового цикла мы возобновляем выполнение текущей катсцены вот так:

coroutine.resume(c, dt)

И наконец, создание катсцены:

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)

Вот, как реализована функция delay:

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

Создание таких врапперов значительно повышает читаемость кода катсцен. DelayAction реализован вот так:

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

Эта реализация идентична той, которой мы использовали в action list'ах! Давайте теперь снова взглянем на функцию Action:launch:

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

Главное здесь — цикл while, который выполняется до тех пор, пока действие не завершится. Это выглядит примерно вот так:



Давайте теперь посмотрим на функцию 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

Корутины отлично сочетаются с событиями (event'ами). Реализуем класс 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

Данной функции не нужен метод update. Оно будет выполняться (хотя ничего делать не будет...) до тех пор, пока не получит событие с нужным типом. Вот практическое применение данного класса — реализация функции say:

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

Просто и читаемо. Когда диалоговое окно закрывается, оно посылает событие с типом 'DialogueWindowClosed`. Действие «say» завершается и своё выполнение начинает следующее за ним.

С помощью корутин можно легко создавать нелинейные катсцены и деревья диалогов:

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



В данном примере функция say чуть более сложная, чем та, которую я показал ранее. Она возвращает выбор игрока в диалоге, однако реализовать это не сложно. Например, внутри может использоваться WaitForEventAction, который словит событие PlayerChoiceEvent и затем вернёт выбор игрока, информация о котором будет содержаться в объекте события.

Чуть более сложные примеры


С помощью корутин можно легко создавать туториалы и небольшие квесты. Например:

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



Корутины также можно использовать для AI. Например, можно сделать функцию, с помощью которой монстр будет двигаться по какой-то траектории:

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



Когда монстр увидит игрока, мы можем просто перестать выполнять корутину и удалить её. Поэтому бесконечный цикл (while true) внутри followPath на самом деле не является бесконечным.

Ещё с помощью корутин можно делать «параллельные» действия. Катсцена перейдёт к следующему действию только после завершения обоих действий. Например, сделаем катсцену, где девочка и кот идут к какой-то точке другу с разными скоростями. После того, как они приходят к ней, кот говорит «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

Самая важная часть здесь — функция waitForFinish, которая является враппером вокруг класса WaitForFinishAction, который можно имплементировать следующим образом:

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

Можно сделать этот класс более мощным, если позволить синхронизацию N-ного количества действий.

Также можно сделать класс, который будет ждать, пока одна из корутин завершится, вместо ожидания, пока все корутины завершает выполнение. Например, это может использоваться в гоночных мини-играх. Внутри корутины будет ожидание, пока один из гонщиков достигнет финиша и затем выполнить какую-нибудь последовательность действий.

Достоинства и недостатки корутин


Корутины — это очень полезный механизм. С помощью них можно писать катсцены и геймплейный код, который легко читается и модифицируется. Катсцены такого вида легко смогут писать моддеры или люди, которые не являются программистами (например, дизайнеры игр или уровней).

И всё это выполняется в одном потоке, поэтому нет проблем с синхронизацией или состоянием гонки (race condition).

У подхода есть недостатки. Например, могут возникнуть проблемы с сохранениями. Допустим, в вашей игре будет длинный туториал, реализованный с помощью корутин. Во время этого туториала игрок не сможет сохраняться, т.к. для этого нужно будет сохранить текущее состояние корутины (что включает весь её стек и значения переменных внутри), чтобы при дальнейшей загрузке из сохранения можно было продолжить выполнение туториала.

(Примечание: с помощью библиотеки PlutoLibrary корутины можно сериализовать, но библиотека работает только с Lua 5.1)

Эта проблема не возникает с катсценами, т.к. обычно в играх сохраняться в середине катсцены не разрешается.

Проблему с длинным туториалом можно решить, если разбить его на небольшие куски. Допустим, игрок проходит первую часть туториала и должен идти в другую комнату, чтобы продолжить туториал. В этот момент можно сделать чекпоинт или дать игроку возможность сохраниться. В сохранении мы запишем что-то вроде «игрок прошёл часть 1 туториала». Далее, игрок пройдёт вторую часть туториала, для которого мы уже будем использовать другую корутину. И так далее… При загрузке, мы просто начнём выполнение корутины, соответствующей части, которую игрок должен пройти.

Заключение


Как можно видеть, для реализации последовательности действий и катсцен есть несколько разных подходов. Мне кажется, что подход с корутинами является очень мощным и я рад поделиться им с разработчиками. Надеюсь, что это решение проблемы сделает вашу жизнь легче и позволит делать вам эпичные катсцены в ваших играх.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 6

    +1
    На мой взгляд Lua ещё более интуитивно понятный язык, чем Python
      +2

      Согласен. Язык сам по себе проще, поэтому практически все тонкости можно освоить за считанные дни. Но, к сожалению, в Lua слабая стандартная библиотека, поэтому часто приходится писать велосипеды для всего подряд. В этом плане Python выигрывает.

        0
        Да, это большая боль, слишком большая свобода и отсутствие рамок очень мешают. Когда начал что-то программировать для Löve, обнаружил, что только одних библиотек для реализации наследования и типа классов вагон и маленькая тележка.
        +1
        Тоже так считаю. Lua для тех, кто не любит ";", но терпеть не может синтаксис пайтона с табуляцией.
          0
          Те, кто любит ";", могут продолжать её использовать в Lua.
        +1
        FYI подобный подход использовался в редакторе игры Warcraft3 (15+ лет назад) и, из недавнего, в игре Pyre (все катсцены и сюжетные моменты там как раз на Lua, открытый код лежит в папочке игры). И там и там используется event-driven архитектура, где каждое срабатывание события создает свой «поток» (коротину), в которой можно использовать блокирующие действия любого типа.

        Only users with full accounts can post comments. Log in, please.