Implementasi cutscene dan urutan aksi dalam game

Dalam posting ini saya akan berbicara tentang bagaimana Anda dapat menerapkan urutan aksi dan adegan cut dalam video game. Artikel ini adalah terjemahan dari artikel ini dan pada topik yang sama saya membuat presentasi di Lua di Moskow, jadi jika Anda lebih suka menonton video, Anda dapat menontonnya di sini .

Kode artikel ditulis dalam Lua, tetapi dapat dengan mudah ditulis dalam bahasa lain (dengan pengecualian metode yang menggunakan coroutine, karena mereka tidak dalam semua bahasa).

Artikel ini menunjukkan cara membuat mekanisme yang memungkinkan Anda untuk menulis adegan cutscene dari formulir berikut:

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 

Entri


Urutan tindakan sering ditemukan di video game. Misalnya, dalam adegan cutscenes: karakter bertemu musuh, mengatakan sesuatu kepadanya, musuh menjawab, dan sebagainya. Urutan tindakan dapat ditemukan di gameplay. Lihatlah gif ini:



1. Pintu terbuka
2. Karakter memasuki rumah
3. Pintu tertutup
4. Layar secara bertahap menjadi gelap
5. Levelnya berubah
6. Layar memudar dengan cerah
7. Karakter memasuki kafe

Urutan tindakan juga dapat digunakan untuk skrip perilaku NPC atau untuk menerapkan pertempuran bos di mana bos melakukan beberapa tindakan satu demi satu.

Masalah


Struktur loop permainan standar membuat penerapan urutan tindakan sulit. Katakanlah kita memiliki lingkaran permainan berikut:



 while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end 

Kami ingin menerapkan cutscene berikut: pemain mendekati NPC, NPC mengatakan: "Anda berhasil!", Dan kemudian setelah jeda singkat mengatakan: "Terima kasih!". Di dunia yang ideal, kita akan menulisnya seperti ini:

 player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") 

Dan di sini kita dihadapkan dengan masalah. Butuh beberapa waktu untuk menyelesaikan langkah-langkah ini. Beberapa tindakan bahkan mungkin menunggu masukan dari pemain (misalnya, untuk menutup kotak dialog). Alih-alih fungsi delay , Anda tidak dapat memanggil sleep sama - ini akan terlihat seperti permainan dibekukan.

Mari kita lihat beberapa pendekatan untuk menyelesaikan masalah.

bool, enum, mesin negara


Cara yang paling jelas untuk menerapkan urutan tindakan adalah menyimpan informasi tentang keadaan saat ini dalam bools, string, atau enum. Kode akan terlihat seperti ini:

 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 

Pendekatan ini dengan mudah mengarah pada kode spaghetti dan rantai panjang ekspresi if-else, jadi saya sarankan menghindari metode pemecahan masalah ini.

Daftar tindakan


Daftar tindakan sangat mirip dengan mesin negara. Daftar tindakan adalah daftar tindakan yang dilakukan satu demi satu. Dalam loop game, fungsi update dipanggil untuk tindakan saat ini, yang memungkinkan kami untuk memproses input dan membuat game, bahkan jika tindakan itu membutuhkan waktu lama. Setelah tindakan selesai, kami melanjutkan ke yang berikutnya.

Dalam cutscene yang ingin kami terapkan, kami perlu mengimplementasikan tindakan berikut: GoToAction, DialogueAction, dan DelayAction.

Untuk contoh lebih lanjut, saya akan menggunakan pustaka middleclass untuk OOP di Lua.

Begini cara DelayAction diimplementasikan:

 --  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 fungsi ActionList:update terlihat seperti ini:

 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 

Dan akhirnya, implementasi cutscene itu sendiri:

 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) 

Catatan : di Lua, panggilan ke someFunction({ ... }) dapat dibuat seperti ini: someFunction{...} . Ini memungkinkan Anda untuk menulis DelayAction:new{ delay = 0.5 } alih-alih DelayAction:new({delay = 0.5}) .

Terlihat jauh lebih baik. Kode dengan jelas menunjukkan urutan tindakan. Jika kita ingin menambahkan tindakan baru, kita dapat dengan mudah melakukannya. Sangat mudah untuk membuat kelas seperti DelayAction untuk membuat cutscene penulisan lebih nyaman.

Saya menyarankan Anda untuk melihat presentasi Sean Middleditch tentang daftar tindakan, yang memberikan contoh yang lebih kompleks.


Daftar tindakan umumnya sangat bermanfaat. Saya menggunakan mereka untuk permainan saya untuk beberapa waktu dan secara keseluruhan senang. Namun pendekatan ini juga memiliki kelemahan. Katakanlah kita ingin menerapkan cutscene yang sedikit lebih kompleks:

 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 

Untuk melakukan simulasi if / else, Anda perlu mengimplementasikan daftar non-linear. Ini dapat dilakukan dengan menggunakan tag. Beberapa tindakan dapat ditandai, dan kemudian, dengan beberapa syarat, alih-alih pindah ke tindakan berikutnya, Anda bisa pergi ke tindakan yang memiliki tag yang diinginkan. Ini berfungsi, namun tidak mudah untuk membaca dan menulis seperti fungsi di atas.

Lua coroutine membuat kode ini menjadi kenyataan.

Coroutine


Dasar-dasar Corua di Lua


Corutin adalah fungsi yang dapat dijeda dan kemudian dilanjutkan. Coroutine dieksekusi di utas yang sama dengan program utama. Tidak ada utas baru yang pernah dibuat untuk coroutine.

Untuk menjeda coroutine.yield , Anda perlu menghubungi coroutine.yield , untuk melanjutkan - coroutine.resume . Contoh sederhana:

 local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c) 

Output Program:

 halo
 uhh ...
 dunia


Begini cara kerjanya. Pertama, kita membuat coroutine.create menggunakan coroutine.create . Setelah panggilan ini, corutin tidak mulai. Agar ini terjadi, kita perlu menjalankannya menggunakan coroutine.resume . Kemudian fungsi f disebut, yang menulis "halo" dan menjeda dirinya sendiri dengan coroutine.yield . Ini mirip dengan return , tetapi kita dapat melanjutkan f dengan coroutine.resume .

Jika Anda melewatkan argumen saat memanggil coroutine.yield , maka mereka akan menjadi nilai balik dari panggilan yang sesuai ke coroutine.resume di "aliran utama".

Sebagai contoh:

 local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text) -- will print '42 "some text"' 

ok adalah variabel yang memungkinkan kita untuk mengetahui status coroutine. Jika ok true , maka dengan coroutine semuanya baik-baik saja, tidak ada kesalahan terjadi di dalam. Nilai-nilai pengembalian yang mengikutinya ( num , text ) adalah argumen yang sama yang kami berikan untuk yield .

Jika ok false , maka ada yang salah dengan coroutine, misalnya, fungsi error dipanggil di dalamnya. Dalam hal ini, nilai pengembalian kedua akan menjadi pesan kesalahan. Contoh coroutine di mana kesalahan terjadi:

 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 

Kesimpulan:

 Coroutine gagal!  input: 4: upaya untuk melakukan aritmatika pada nilai nol (global 'notDefined')


Status coroutine.status dapat diperoleh dengan memanggil coroutine.status . Corutin mungkin dalam kondisi berikut:

  • "Running" - Coroutine sedang berjalan. coroutine.status dipanggil dari corutin itu sendiri
  • "Ditangguhkan" - Corutin dijeda atau belum pernah dimulai
  • "Normal" - corutin aktif, tetapi tidak dieksekusi. Artinya, corutin meluncurkan corutin lain di dalam dirinya
  • "Mati" - eksekusi coroutine selesai (yaitu, fungsi dalam coroutine selesai)

Sekarang, dengan bantuan pengetahuan ini, kita dapat menerapkan sistem urutan tindakan dan adegan cutscene berdasarkan coroutine.

Membuat cutscene menggunakan corutin


Inilah yang akan terlihat seperti kelas Action pada sistem baru:

 function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end 

Pendekatannya mirip dengan daftar tindakan: fungsi update dari tindakan dipanggil hingga tindakan selesai. Tapi di sini kita menggunakan coroutine dan yield di setiap iterasi dari loop game ( Action:launch dipanggil dari beberapa coroutine). Di suatu tempat dalam update loop game, kami melanjutkan eksekusi cutscene saat ini seperti ini:

 coroutine.resume(c, dt) 

Dan akhirnya, membuat cutscene:

 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) 

Inilah cara fungsi delay diimplementasikan:

 function delay(time) action = DelayAction:new { delay = time } action:launch() end 

Membuat pembungkus seperti itu sangat meningkatkan keterbacaan kode cutscene. DelayAction diimplementasikan seperti ini:

 -- 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 

Implementasi ini identik dengan yang kami gunakan dalam daftar tindakan! Mari kita lihat Action:launch fungsi Action:launch lagi:

 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 

Hal utama di sini adalah while , yang berjalan hingga aksi selesai. Itu terlihat seperti ini:



Sekarang mari kita lihat fungsi 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 

Coroutine berjalan baik dengan berbagai acara. Menerapkan kelas 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 

Fungsi ini tidak memerlukan metode update . Itu akan dieksekusi (meskipun tidak akan melakukan apa-apa ...) sampai menerima acara dengan jenis yang diperlukan. Ini adalah aplikasi praktis dari kelas ini - implementasi fungsi say :

 function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end 

Sederhana dan mudah dibaca. Ketika kotak dialog ditutup, ia mengirimkan acara bertipe 'DialogueWindowClosed`. Tindakan say berakhir dan yang berikutnya mulai dijalankan.

Menggunakan coroutine, Anda dapat dengan mudah membuat cutscenes nonlinear dan pohon dialog:

 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 



Dalam contoh ini, fungsi say sedikit lebih kompleks daripada yang saya tunjukkan sebelumnya. Ini mengembalikan pilihan pemain dalam dialog, namun tidak sulit untuk diterapkan. Misalnya, WaitForEventAction dapat digunakan secara WaitForEventAction , yang akan menangkap acara PlayerChoiceEvent dan kemudian mengembalikan pilihan pemain yang informasinya akan terkandung dalam objek acara.

Contoh yang sedikit lebih rumit


Dengan coroutine, Anda dapat dengan mudah membuat tutorial dan pencarian kecil. Sebagai contoh:

 girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!") 



Coroutine juga dapat digunakan untuk AI. Misalnya, Anda dapat membuat fungsi yang akan membuat monster bergerak di sepanjang lintasan:

 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 



Ketika monster melihat pemain, kita bisa berhenti memenuhi coroutine dan menghapusnya. Oleh karena itu, loop tak terbatas ( while true ) di dalam followPath tidak benar-benar tak terbatas.

Dengan corutin, Anda dapat melakukan tindakan "paralel". Cutscene tidak akan melanjutkan ke tindakan berikutnya sampai kedua tindakan selesai. Sebagai contoh, kami akan membuat cutscene di mana seorang gadis dan kucing pergi ke titik teman dengan kecepatan yang berbeda. Setelah mereka mendatanginya, kucing itu berkata "mengeong".

 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 

Bagian terpenting di sini adalah fungsi waitForFinish , yang merupakan pembungkus di sekitar kelas WaitForFinishAction , yang dapat diimplementasikan sebagai berikut:

 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 

Anda dapat membuat kelas ini lebih kuat jika Anda mengizinkan sinkronisasi jumlah tindakan ke-N.

Anda juga dapat membuat kelas yang akan menunggu sampai salah satu coroutine selesai, alih-alih menunggu semua coroutine untuk menyelesaikan eksekusi. Misalnya, dapat digunakan dalam balap mini-game. Di dalam coroutine akan ada menunggu salah satu pembalap untuk mencapai garis finish dan kemudian melakukan beberapa tindakan.

Keuntungan dan kerugian dari corutin


Coroutine adalah mekanisme yang sangat berguna. Dengan menggunakannya Anda dapat menulis cutscene dan kode gameplay yang mudah dibaca dan dimodifikasi. Adegan seperti ini dapat dengan mudah ditulis oleh modder atau orang yang bukan programmer (misalnya, desainer game atau level).

Dan semua ini dilakukan dalam satu utas, sehingga tidak ada masalah dengan sinkronisasi atau kondisi balapan .

Pendekatan ini memiliki kelemahan. Misalnya, mungkin ada masalah dengan save. Katakanlah permainan Anda memiliki tutorial panjang yang diimplementasikan dengan coroutine. Selama tutorial ini pemain tidak akan dapat menyimpan karena Untuk melakukan ini, Anda harus menyimpan keadaan saat ini dari coroutine (yang mencakup seluruh tumpukan dan nilai-nilai variabel di dalamnya), sehingga setelah memuat lebih lanjut dari save, Anda dapat melanjutkan tutorial.

( Catatan : menggunakan perpustakaan PlutoLibrary, coroutine dapat diserialisasi, tetapi perpustakaan hanya berfungsi dengan Lua 5.1)

Masalah ini tidak terjadi dengan cutscene, seperti biasanya dalam game di tengah cutscene tidak diperbolehkan.

Masalah dengan tutorial yang panjang dapat diselesaikan jika Anda memecahnya menjadi potongan-potongan kecil. Misalkan seorang pemain melewati bagian pertama tutorial dan harus pergi ke ruangan lain untuk melanjutkan tutorial. Pada titik ini, Anda dapat membuat pos pemeriksaan atau memberikan pemain kesempatan untuk menabung. Dalam save, kami akan menulis sesuatu seperti "pemain menyelesaikan bagian 1 dari tutorial". Selanjutnya, pemain akan melalui bagian kedua tutorial, yang mana kita sudah akan menggunakan coroutine lain. Dan seterusnya ... Saat memuat, kami baru mulai menjalankan coroutine yang sesuai dengan bagian yang harus dilalui pemain.

Kesimpulan


Seperti yang dapat Anda lihat, ada beberapa pendekatan berbeda untuk mengimplementasikan serangkaian tindakan dan adegan adegan. Tampaknya bagi saya bahwa pendekatan coroutine sangat kuat dan saya senang membagikannya kepada para pengembang. Saya harap solusi masalah ini akan membuat hidup Anda lebih mudah dan memungkinkan Anda membuat adegan-adegan epik dalam gim Anda.

Source: https://habr.com/ru/post/id427135/


All Articles