Імплементація катсцен і послідовностей дій в іграх

У цьому пості я розповім про те, як можна імплементувати послідовності дій і катсцени у відеоіграх. Ця стаття є перекладом цієї статті і по цій же темі я робив доповідь на 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 — це список дій, які виконуються одне за іншим. В ігровому циклі для поточного дії викликається функція оновлення, що дозволяє нам обробляти enter і рендери гру, навіть якщо дія виконується тривалий час. Після того, як дію завершено, ми переходимо до виконання наступного.

У катсцене, яку ми хочемо реалізувати, нам треба імплементувати наступні дії: 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’и, в якій наводяться більш складні приклади.

https://www.youtube.com/embed/o6CaB-hmqoE
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

ок, 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)
ок, 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

Корутины відмінно поєднуються з подіями (подієві ами). Реалізуємо клас 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('сердитися')
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 туториала». Далі, гравець пройде другу частину туториала, для якого ми вже будемо використовувати іншу корутину. І так далі… При завантаженні, ми просто почнемо виконання корутины, відповідної частини, яку гравець повинен пройти.

Висновок
Як можна бачити, для реалізації послідовності дій і катсцен є кілька різних підходів. Мені здається, що підхід з корутинами є дуже потужним і я радий поділитися ним з розробниками. Сподіваюся, що це рішення проблеми зробить ваше життя легше і дозволить робити вам епічні катсцени у ваших іграх.

Читайте також  Вивантажуємо дані в Excel. Цивілізовано

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *