Хабрахабр

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

В этом посте я расскажу о том, как можно имплементировать последовательности действий и катсцены в видеоиграх. Эта статья является переводом вот этой статьи и по этой же теме я делал доклад на 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

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

Открывается дверь
2. 1. Дверь закрывается
4. Персонаж заходит в дом
3. Меняется уровень
6. Экран плавно темнеет
5. Персонаж заходит в кафе Экран плавно светлеет
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 , 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. После этого вызова корутина не начинает выполняться. Это похоже на return, но мы можем возобновить выполнение f с помощью coroutine.resume. Затем вызывается функция f, которая пишет «hello» и ставит себя на паузу с помощью coroutine.yield.

Если передать аргументы при вызове 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).

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть