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