Como usar coroutines em cutscenes para games em C#

Quando começamos algo novo é sempre interessante entender como pessoas mais experientes resolvem problemas mais comuns, e uma das dúvidas que me surgiam com alguma frequência era como alguns desenvolvedores programavam e principalmente como organizavam cinemáticas ou cutscenes em seus jogos.

Se você usa Unity muito provavelmente conhece o método StartCoroutine da API da engine.

Neste caso poderíamos criar uma coroutine para a cutscene a cima de uma maneira bem simples como o exemplo abaixo.

// interface
public interface IInteractable
{
    void Interact();
}

// GuardDoor.cs
public class GuardDoor : MonoBehaviour, IInteractable
{
    //...
    public void Interact()
    {
        StartCoroutine(CutScene());
    } 

    private IEnumerator CutScene()
    {
        if(Door.isClosed)
        {
            Debug.Log("I'm the door guard");
            Debug.Log("I don't actually like doors");
            // resto do dialogo

            Door.Animation.Play("pull");
            yield return new WaitForSeconds(0.7f);
            
            Vector2 target = Camera.Position + new Vector2(160f, 1.0f);
            while(Vector2.Distance(Camera.Position, target) > 0.5f)
            {
                Camera.Position = Vector2.Lerp(Camera.Position, target, speed * Time.DeltaTime);
                yield return null;
            }

            // resto do comportamento da cutscene ...
        }

        Debug.Log("don't tell anyone about my lack of door guarding");
    }
    //...
}

Observe que fiz umas adaptações para C# e para os recursos que a Unity oferece e talvez não funcione da maneira corretar em comparação ao código que o Noel compartilhou, já que em sua versão ele utiliza a linguagem lua para escrever as suas cutscenes. Também fiz uma interface para o método Interact, ou seja, agora podemos replicar esse comportamento em outras situações, em outras palavras, se um objeto possui esse componente poderemos sempre aciona-lo, como por exemplo quando o player ou outro objeto colidi com uma hitbox poderíamos chama-los. Em outro post poderemos falar mais sobre interfaces e até mesmo falar sobre S.O.L.I.D para desenvolvimento de games, mas neste momento vamos nos atentar as coroutines que é o assunto deste artigo.

Se você me acompanha sabe que apesar de entender de Unity, normalmente utilizo MonoGame/XNA em meus projetos pessoais, e bem… no MonoGame não temos o método StartCorountine, já que tal recurso faz parte da API do Unity. Então como trabalhos com coroutines com C# puro? Talvez a melhor maneira de começar a falar sobre isso seja do começo.

O que é um IEnumerator?

Um IEnumerator de acordo com a documentação do C# é a interface base para todos os enumeradores não genéricos. Seu equivalente genérico é System.Collections.Generic.IEnumerator<T> interface.

E o que isso quer dizer?

Se você criar uma variável do tipo list ou Queue em um código c# e clicar com botão esquerdo segurando CTRL sobre a o tipo da variável vai encontrar a sua classe.

Se esta familiarizado com o conceito de interfaces, que inclusive já usamos nesse artigo, entende que a grosso modo, interfaces são usadas para extender ou adicionar componentes a uma classe. E neste caso, ambas as classes usam a interface IEnumerable, que quando checamos encontramos? Adivinhe!

Isso quer dizer que:

//podemos fazer isso
List<int> numerosList = new List<int>();
numerosList.GetEnumerator();

// ou isso
Queue<int> numerosQueue = new Queue<int>();
numerosQueue.GetEnumerator();

Ou seja, temos um IEnumerator a partir de um list ou queue, mas acho que ainda não estou sendo claro.

Se fossemos imprimir todos os valores de uma variável do tipo list, poderíamos usar um foreach, for ou até mesmo um while certo?

List<int> numerosList = new List<int>();
numerosList.Add(1);
numerosList.Add(2);
numerosList.Add(3);
numerosList.Add(4);

foreach(int numero in numerosList)
{
    Console.WriteLine(numero);
}

Pode se dizer que o que ocorre quando é feito um foreach é basicamente isso:

IEnumerator<int> numeros = numerosList.GetEnumerator();
while (numeros.MoveNext())
{
    Console.WriteLine(numeros.Current);
}

Um IEnumerable possui o método GetEnumerator, que como o nome sugere tem como retorno um IEnumerator. Quando usamos o método MoveNext estamos mudando o ponteiro na lista para o próximo item, e obtemos esse item usando a variável Current.

E agora explorando um pouco mais poderíamos fazer isso:

class Program
{
    static void Main()
    {
        IEnumerator<int> numeros = numerosList();
        while (numeros.MoveNext())
        {
            Console.WriteLine(numeros.Current);
        }
    }

    public static IEnumerator<int> numerosList()
    {
        yield return 1;
        yield return 2;
        yield return 3;
        yield return 4;
    }
}

Mas o que quer dizer essa palavrinha yield agora? Bem de acordo com a documentação do c# novamente: “Ao usar a yield em uma instrução, você indica que o método, o operador ou o acessador get em que ela é exibida é um iterator. Usar yield para definir um iterator elimina a necessidade de uma classe adicional explícita (a classe que mantém o estado de uma enumeração …“.

Então quando usamos yield return, estamos chamando cada item da lista individualmente.

Já que criamos esse método que retorna um número inteiro sempre que usamos MoveNext, poderíamos, antes do retorno, executar outras coisas como por exemplo:

public static IEnumerator<int> numerosList()
{
    Console.WriteLine("estamos no valor de 0");
    yield return 1;
    Console.WriteLine("estamos no valor de 1");
    yield return 2;
    Console.WriteLine("estamos no valor de 2");
    yield return 3;
    Console.WriteLine("estamos no valor de 3");
    yield return 4;
}

Mas como usamos isso?

Então com base no que vimos, eu criei uma classe chamada CoroutineManagement.

using System.Collections;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
public class CoroutineManagement
{
    private List<IEnumerator> _coroutines = new List<IEnumerator>();
    private IEnumerator _coroutinesUpdate;

    public CoroutineManagement()
    {
        _coroutinesUpdate = coroutinesUpdate();
    }

    public void StarCoroutine(IEnumerator coroutine)
    {
        _coroutines.Add(coroutine);
    }

    public void ClearCoroutines()
    {
        _coroutines.Clear();
    }

    public void Update(GameTime gameTime)
    {
        _coroutinesUpdate.MoveNext();
    }

    private IEnumerator coroutinesUpdate()
    {
        while (true)
        {
            if (_coroutines.Count > 0)
            {
                ExecuteCoroutine();
            }
            yield return null;
        }
    }

    private void ExecuteCoroutine()
    {
        bool hasCoroutines = _coroutines[0].MoveNext();

        if (!hasCoroutines)
        {
            _coroutines.RemoveAt(0);
            coroutinesUpdate().MoveNext();
        }
    }
}

Optei por usar uma variável do tipo List chamada _coroutines, desta forma podemos ter uma lista de coroutines que quando for concluída passara para a próxima. Como no exemplo abaixo.

using Microsoft.Xna.Framework;

namespace GameProject
{
    public class Game1 : Game
    {
        //..
        
        public CoroutineManagement Coroutine;

        public void Initialize()
        {
            CoroutineManagement Coroutine = new CoroutineManagement();
            Coroutine.StarCoroutine(CutScene1());
            Coroutine.StarCoroutine(CutScene2());
            base.Initialize();
        }

        public void Update(GameTime gameTime)
        {
            Coroutine.Update(gameTime);
        }

        // nossas coroutines
        IEnumerator CutScene1()
        {
            System.Console.WriteLine("CutScene 1");
            yield return null;
        }

        IEnumerator CutScene2()
        {
            System.Console.WriteLine("CutScene 2");
            yield return null;
        }
        //...
    }
}

Porém se você bem lembra, a Unity tem um outro método chamado WaitForSeconds, com ele podemos informar uma quantidade de tempo de espera para executar a próxima ação. Como poderíamos fazer?

// CoroutineManagement.cs
//...
private float _gameTime;
public void Update(GameTime gameTime)
{
    _gameTime = gameTime;
    _coroutinesUpdate.MoveNext();
}

public IEnumerator Wait(float time)
{
    float timer = time;
    while (timer >= 0)
    {
        timer -= (float)_gameTime.ElapsedGameTime.TotalMilliseconds;
        yield return null;
    }
    yield break;
}

private void ExecuteCoroutine()
{
    bool hasCoroutines = _coroutines[0].MoveNext();

    if (!hasCoroutines)
    {
        _coroutines.RemoveAt(0);
        coroutinesUpdate().MoveNext();
    }

    if (hasCoroutines && _coroutines[0].Current != null)
    {
        IEnumerator curoutine = (IEnumerator)_coroutines[0].Current;
        curoutine.MoveNext();
        _coroutines.Insert(0, curoutine);
    }
}
//..

Basicamente criei uma outra coroutine e coloquei ela no começo da lista, então ela precisa ser finalizada para só então iniciar a coroutine seguinte, que neste caso é a cutscene2, e seria usado dessa forma:

// Game1.cs
// ...
IEnumerator CutScene1()
{
    System.Console.WriteLine("CutScene 1");
    yield return Coroutine.Wait(2000);
}
// ...  

E isso é tudo pessoal. E ai você gostou? Esse artigo foi útil de alguma maneira pra você? E eu prometo que os próximos artigos não vão demorar mais tanto. Até apróxima.

Leave a comment