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