Implementação de cenas e sequências de ações em jogos

Neste post, falarei sobre como você pode implementar seqüências de ação e cenas em videogames. Este artigo é uma tradução deste artigo e, sobre o mesmo tópico, fiz uma apresentação na Lua em Moscou, portanto, se você preferir assistir ao vídeo, assista aqui .

O código do artigo é escrito em Lua, mas pode ser facilmente escrito em outros idiomas (com exceção do método que usa corotinas, porque eles não estão em todos os idiomas).

O artigo mostra como criar um mecanismo para gravar cenas do seguinte formato:

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 

Entrada


Sequências de ação são frequentemente encontradas em videogames. Por exemplo, em cenas: o personagem encontra o inimigo, diz alguma coisa para ele, o inimigo responde e assim por diante. A sequência de ações pode ser encontrada na jogabilidade. Dê uma olhada neste gif:



1. A porta se abre
2. O personagem entra na casa
3. A porta se fecha
4. A tela escurece gradualmente
5. O nível muda
6. A tela desaparece intensamente
7. O personagem entra no café

Sequências de ações também podem ser usadas para criar um script do comportamento dos NPCs ou implementar batalhas contra chefes nas quais o chefe executa algumas ações uma após a outra.

O problema


A estrutura de um loop de jogo padrão dificulta a implementação de seqüências de ação. Digamos que temos o seguinte loop de jogo:



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

Queremos implementar a seguinte cena: o jogador se aproxima do NPC, o NPC diz: “Você conseguiu!”, E depois de uma breve pausa diz: “Obrigado!”. Em um mundo ideal, escreveríamos assim:

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

E aqui estamos diante de um problema. Leva algum tempo para concluir as etapas. Algumas ações podem até aguardar a entrada do player (por exemplo, para fechar a caixa de diálogo). Em vez da função de delay , você não pode chamar o mesmo sleep - parece que o jogo está congelado.

Vamos dar uma olhada em algumas abordagens para resolver o problema.

bool, enum, máquinas de estado


A maneira mais óbvia de implementar uma sequência de ações é armazenar informações sobre o estado atual em bools, linhas ou enumerações. O código será algo como isto:

 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 

Essa abordagem leva facilmente ao código de espaguete e a longas cadeias de expressões if-else, portanto, recomendo evitar esse método de resolver o problema.

Lista de ações


As listas de ações são muito semelhantes às máquinas de estado. Uma lista de ações é uma lista de ações que são executadas uma após a outra. No loop do jogo, a função de update é chamada para a ação atual, o que nos permite processar a entrada e renderizar o jogo, mesmo que a ação demore muito. Após a conclusão da ação, prosseguimos para a próxima.

Na cena que queremos implementar, precisamos implementar as seguintes ações: GoToAction, DialogueAction e DelayAction.

Para mais exemplos, usarei a biblioteca middleclass para OOP em Lua.

Veja como um DelayAction implementado:

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

A função ActionList:update fica assim:

 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 

E, finalmente, a implementação da própria cena:

 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) 

Nota : em Lua, uma chamada para someFunction({ ... }) pode ser feita assim: someFunction{...} . Isso permite que você escreva DelayAction:new{ delay = 0.5 } vez de DelayAction:new({delay = 0.5}) .

Parece muito melhor. O código mostra claramente a sequência de ações. Se queremos adicionar uma nova ação, podemos fazê-lo facilmente. É muito simples criar classes como DelayAction para tornar as cenas de gravação mais convenientes.

Aconselho que você veja a apresentação de Sean Middleditch sobre listas de ações, que fornece exemplos mais complexos.


As listas de ações geralmente são muito úteis. Eu os usei nos meus jogos por um bom tempo e no geral fiquei feliz. Mas essa abordagem também tem desvantagens. Digamos que queremos implementar uma cena um pouco mais complexa:

 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 

Para fazer uma simulação if / else, você precisa implementar listas não lineares. Isso pode ser feito usando tags. Algumas ações podem ser marcadas e, em seguida, por alguma condição, em vez de passar para a próxima ação, você pode ir para uma ação com a tag desejada. Funciona, no entanto, não é tão fácil de ler e escrever como a função acima.

As corotinas de Lua tornam esse código uma realidade.

Coroutines


Noções básicas de Corua em Lua


Corutin é uma função que pode ser pausada e depois retomada. As corotinas são executadas no mesmo encadeamento que o programa principal. Nenhum novo encadeamento é criado para a rotina.

Para pausar a coroutine.yield , é necessário chamar coroutine.yield , para retomar - coroutine.resume . Um exemplo simples:

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

Saída do Programa:

 ola
 uhh ...
 mundo


Aqui está como isso funciona. Primeiro, criamos coroutine.create usando coroutine.create . Após esta chamada, o corutin não inicia. Para que isso aconteça, precisamos executá-lo usando coroutine.resume . Em seguida, a função f é chamada, que escreve “olá” e faz uma pausa com coroutine.yield . É semelhante ao return , mas podemos retomar f com coroutine.resume .

Se você passar argumentos ao chamar coroutine.yield , eles se tornarão os valores de retorno da chamada correspondente a coroutine.resume no "fluxo principal".

Por exemplo:

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

ok é uma variável que nos permite conhecer o status de uma corotina. Se ok for true , com a rotina está tudo bem, não ocorreram erros no interior. Os valores de retorno a seguir ( num , text ) são os mesmos argumentos que passamos para yield .

Se ok for false , ocorreu um erro na corotina, por exemplo, a função de error foi chamada dentro dela. Nesse caso, o segundo valor de retorno será uma mensagem de erro. Um exemplo de uma rotina na qual ocorre um erro:

 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 

Conclusão:

 Coroutine falhou!  input: 4: tentativa de executar aritmética em um valor nulo (global 'notDefined')


O status da coroutine.status pode ser obtido chamando coroutine.status . Corutin pode estar nas seguintes condições:

  • "Em execução" - a Coroutine está em execução no momento. coroutine.status foi chamado da própria corutin
  • "Suspenso" - Corutin foi pausado ou nunca foi iniciado
  • "Normal" - corutin está ativo, mas não é executado. Ou seja, corutin lançou outro corutin dentro de si
  • “Inoperante” - execução concluída da corotina (ou seja, a função dentro da corotina concluída)

Agora, com a ajuda desse conhecimento, podemos implementar um sistema de sequências de ações e cenas baseadas em corotinas.

Criando cenas usando corutin


A seguir, como será a classe Action básica no novo sistema:

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

A abordagem é semelhante às listas de ações: a função de update da ação é chamada até que a ação seja concluída. Mas aqui usamos corotinas e produzimos em cada iteração do loop do jogo ( Action:launch é chamado de alguma corotina). Em algum momento da update loop update jogo, retomamos a execução da cena atual assim:

 coroutine.resume(c, dt) 

E, finalmente, criando uma cena:

 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) 

Veja como a função de delay é implementada:

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

Criar esses invólucros melhora muito a legibilidade do código da cena. DelayAction implementado assim:

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

Essa implementação é idêntica à que usamos nas listas de ações! Vamos dar uma olhada na função Action:launch novamente:

 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 

O principal aqui é o while , que é executado até a ação ser concluída. Parece algo como isto:



Vamos agora olhar para a função 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 

Coroutines vão bem com os eventos. Implemente a classe 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 

Esta função não precisa do método de update . Ele será executado (embora não faça nada ...) até receber um evento com o tipo necessário. Aqui está a aplicação prática desta classe - a implementação da função say :

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

Simples e legível. Quando a caixa de diálogo é fechada, ela despacha um evento do tipo 'DialogueWindowClosed`. A ação say termina e a próxima começa a executar.

Usando a corotina, você pode criar facilmente cenas não lineares e árvores de diálogo:

 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 



Neste exemplo, a função say é um pouco mais complexa do que a que mostrei anteriormente. Ele retorna a escolha do jogador no diálogo, no entanto, não é difícil de implementar. Por exemplo, WaitForEventAction pode ser usado WaitForEventAction , o que capturará o evento PlayerChoiceEvent e retornará a escolha do jogador cujas informações estarão contidas no objeto de evento.

Exemplos ligeiramente mais complexos


Com a corotina, você pode criar facilmente tutoriais e pequenas missões. Por exemplo:

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



Corotinas também podem ser usadas para IA. Por exemplo, você pode criar uma função com a qual o monstro se moverá ao longo de alguma trajetória:

 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 



Quando o monstro vê o jogador, podemos simplesmente parar de cumprir a corotina e removê-la. Portanto, um loop infinito ( while true ) dentro do followPath não é realmente infinito.

Com corutin, você pode executar ações "paralelas". A cena não prosseguirá para a próxima ação até que as duas ações sejam concluídas. Por exemplo, faremos uma cena em que uma menina e um gato vão ao ponto de um amigo em velocidades diferentes. Depois que eles a procuram, o gato diz "miau".

 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 

A parte mais importante aqui é a função waitForFinish , que é um invólucro em torno da classe WaitForFinishAction , que pode ser implementada da seguinte maneira:

 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 

Você pode tornar essa classe mais poderosa se permitir a sincronização do enésimo número de ações.

Você também pode criar uma classe que esperará até que uma das corotinas seja concluída, em vez de esperar que todas as corotinas concluam a execução. Por exemplo, ele pode ser usado em minijogos de corrida. Dentro da corotina, haverá uma espera para que um dos pilotos chegue à linha de chegada e, em seguida, realize alguma sequência de ações.

Vantagens e desvantagens da corutina


As corotinas são um mecanismo muito útil. Usando-os, você pode escrever cenas e código de jogo fáceis de ler e modificar. Cortes desse tipo podem ser facilmente escritos por modders ou pessoas que não são programadores (por exemplo, designers de jogos ou níveis).

E tudo isso é realizado em um encadeamento, portanto, não há problema com a sincronização ou condição de corrida .

A abordagem tem desvantagens. Por exemplo, pode haver problemas com o salvamento. Digamos que seu jogo tenha um longo tutorial implementado com corotina. Durante este tutorial, o jogador não poderá salvar porque Para fazer isso, você precisará salvar o estado atual da corotina (que inclui toda a pilha e os valores das variáveis), para que, após um carregamento adicional do salvamento, você possa continuar o tutorial.

( Nota : usando a biblioteca PlutoLibrary, as corotinas podem ser serializadas, mas a biblioteca funciona apenas com Lua 5.1)

Esse problema não ocorre com as cenas, pois geralmente em jogos no meio da cena não é permitido.

O problema de um longo tutorial pode ser resolvido se você o dividir em pequenos pedaços. Suponha que um jogador passe pela primeira parte de um tutorial e precise ir para outra sala para continuar o tutorial. Nesse ponto, você pode fazer um ponto de verificação ou dar ao jogador a oportunidade de salvar. No save, escreveremos algo como "o jogador concluiu a parte 1 do tutorial". Em seguida, o jogador passará pela segunda parte do tutorial, para a qual já usaremos outra corotina. E assim por diante ... Ao carregar, apenas começamos a executar a corotina correspondente à parte pela qual o jogador deve passar.

Conclusão


Como você pode ver, existem várias abordagens diferentes para implementar uma sequência de ações e cenas. Parece-me que a abordagem da corotina é muito poderosa e estou feliz em compartilhá-la com os desenvolvedores. Espero que essa solução para o problema facilite sua vida e permita que você faça cenas épicas em seus jogos.

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


All Articles