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 ... ...
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:
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
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)
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
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:
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()
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 ...
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

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()
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.