Имплементация катсцен и последовательностей действий в играх
В этом посте я расскажу о том, как можно имплементировать последовательности действий и катсцены в видеоиграх. Эта статья является переводом вот этой статьи и по этой же теме я делал доклад на 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’ах. Код при этом будет выглядеть примерно так:
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 — это список действий, которые выполняются одно за другим. В игровом цикле для текущего действия вызывается функция 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 туториала». Далее, игрок пройдёт вторую часть туториала, для которого мы уже будем использовать другую корутину. И так далее… При загрузке, мы просто начнём выполнение корутины, соответствующей части, которую игрок должен пройти.
Как можно видеть, для реализации последовательности действий и катсцен есть несколько разных подходов. Мне кажется, что подход с корутинами является очень мощным и я рад поделиться им с разработчиками. Надеюсь, что это решение проблемы сделает вашу жизнь легче и позволит делать вам эпичные катсцены в ваших играх.