Кастомные корутины в Unity с преферансом и куртизанками +15




Вы уже настолько круты, что вертите корутинами вокруг всех осей одновременно, от одного вашего взгляда они выполняют yield break и прячутся за полотно IDE. Простые обертки — давно пройденный этап.


Вы настолько хорошо умеете их готовить, что могли бы получить звезду Мишлена (а то и две), будь у вас свой ресторан. Конечно! Никто не останется равнодушным, отведав ваш Буйабес с корутинным соусом.


Уже целую неделю код в проде не падает! Обертки, callback’и и методы Start/Stop Coroutine — удел холопов. Вам нужно больше контроля и свободы действий. Вы готовы подняться на следующую ступеньку (но не бросить корутины, конечно).


Если в этих строках вы узнали себя, — добро пожаловать под кат.


Пользуясь случаем, хочу передать привет одному из моих любимых паттернов — Command.


Введение


Для всех yield инструкций, предоставляемых Unity ( Coroutine, AsyncOperation, WaiForSeconds и другие) базовым является ничем не примечательный класс YieldInstruction(docs):


[StructLayout (LayoutKind.Sequential)]
[UsedByNativeCode]
public class YieldInstruction
{

}

Под капотом, корутины — обычные перечислители (IEnumerator(docs)). Каждый кадр вызывается метод MoveNext(). Если возвращаемое значение равно true — выполнение откладывается до следующего yield оператора, если же false — останавливается.


Для кастомных yield инструкций Unity предоставляет одноименный класс CustomYieldInstruction(docs).


public abstract class CustomYieldInstruction : IEnumerator
{
    public abstract bool keepWaiting
    {
        get;
    }

    public object Current => null;

    public bool MoveNext();

    public void Reset();
}

Достаточно лишь наследоваться от него и реализовать свойство keepWaiting. Его логику мы уже обсудили выше. Оно должно возвращать true до тех пор, пока корутина должна выполняться.


Пример из документации:


using UnityEngine;

public class WaitForMouseDown : CustomYieldInstruction
{
    public override bool keepWaiting
    {
        get
        {
            return !Input.GetMouseButtonDown(1);
        }
    }

    public WaitForMouseDown()
    {
         Debug.Log("Waiting for Mouse right button down");
    }
}

Согласно ей же, геттер keepWating выполняется каждый кадр после Update() и перед LateUpdate().


Если вы работаете с кодом, в котором определенные вещи должны сработать в конкретный момент, и до сих пор не изучили вот эту страницу — настоятельно рекомендую решиться на сей подвиг.


Кастомные yield инструкции



Но мы же с вами сюда пришли не очередные обертки на подобие CustomYieldInstruction наследовать. За такое можно и без Мишлена остаться.


Потому курим дальше документацию и практически на дне всё той же страницы находим самый важный абзац.


To have more control and implement more complex yield instructions you can inherit directly from System.Collections.IEnumerator class. In this case, implement MoveNext() method the same way you would implement keepWaiting property. Additionally to that, you can also return an object in Current property, that will be processed by Unity's coroutine scheduler after executing MoveNext() method. So for example if Current returned another object inheriting from IEnumerator, then current enumerator would be suspended until the returned one has completed.


По-русски

Чтобы получить больше контроля и реализовать более комплексные yield инструкции, вы можете наследоваться непосредственно от интерфейса System.Collections.IEnumerator. В этом случае, реализуйте метод MoveNext() таким же образом, как свойство keepWaiting. Кроме этого, вы можете использовать объект в свойстве Current, который будет обработан планировщиком корутин Unity после выполнения метода MoveNext(). Например, если свойство Current возвращает другой объект реализующий интерфейс IEnumerator, выполнение текущего перечислителя будет отложено, пока не завершится выполнение нового.


Святые моллюски! Это же те контроль и свобода действий, которых так хотелось. Ну всё, теперь то вы так корутинами завертите, что никакой Gimbal Lock не страшен. Главное, от счастья, прод с вертухи не грохнуть.


Интерфейс


Что ж, было бы неплохо начать с того, чтобы определиться, какой интерфейс взаимодействия с нашей инструкцией мы хотим иметь. Некий минимальный набор.


Предлагаю следующий вариант


public interface IInstruction
{
    bool IsExecuting { get; }
    bool IsPaused { get; }

    Instruction Execute();
    void Pause();
    void Resume();
    void Terminate();

    event Action<Instruction> Started;
    event Action<Instruction> Paused;
    event Action<Instruction> Cancelled;
    event Action<Instruction> Done;
}

Хочу обратить ваше внимание, что здесь IsExecuting и IsPaused не противоположные вещи. Если выполнение корутины на паузе, она всё еще выполняется.


Пасьянс и куртизанки


Как гласит документация, нужно реализовать интерфейс IEnumerator. В некоторых местах пока что оставим заглушки, так как его реализация напрямую зависит от того, какой функционал мы хотим в него заложить.


using UnityEngine;
using IEnumerator = System.Collections.IEnumerator;

public abstract class Instruction : IEnumerator
{
    private Instruction current;
    object IEnumerator.Current => current;

    void IEnumerator.Reset()
    {

    }

    bool IEnumerator.MoveNext()
    {

    }
}

Стоит учитывать, что есть как минимум два способа, которыми наша инструкция может быть запущена:


  1. Метод StartCoroutine(IEnumerator routine):


    StartCoroutine(new ConcreteInstruction());

  2. Оператор yield:


    private IEnumerator SomeRoutine()
    {
    yield return new ConcreteInstruction();
    }


Метод Execute, который мы описали выше в интерфейсе IInstruction, будет использовать первый способ. Потому добавим несколько полей, которые помогут управлять инструкцией в этом случае.


private object routine;
public MonoBehaviour Parent { get; private set; }

Теперь свойства и события для IInstruction.


using UnityEngine;
using System;
using IEnumerator = System.Collections.IEnumerator;

public abstract class Instruction : IEnumerator, IInstruction
{
    private Instruction current;
    object IEnumerator.Current => current;

    private object routine;
    public MonoBehaviour Parent { get; private; }

    public bool IsExecuting { get; private set; }
    public bool IsPaused { get; private set; }

    private bool IsStopped { get; set; }

    public event Action<Instruction> Started;
    public event Action<Instruction> Paused;
    public event Action<Instruction> Terminated;
    public event Action<Instruciton> Done;

    void IEnumerator.Reset()
    {

    }

    bool IEnumerator.MoveNext()
    {

    }

    Instruction(MonoBehaviour parent) => Parent = parent;
}

Также, методы для обработки событий в дочерних классах:


protected virtual void OnStarted() { }
protected virtual void OnPaused() { }
protected virtual void OnResumed() { }
protected virtual void OnTerminated() { }
protected virtual void OnDone() { }

Они будут вызываться вручную непосредственно перед соответствующими событиями, чтобы предоставить дочерним классам приоритет их обработки.


Теперь оставшиеся методы.


public void Pause()
{
    if (IsExecuting && !IsPaused)
    {
        IsPaused = true;

        OnPaused();
        Paused?.Invoke(this);
    }
}

public bool Resume()
{
    if (IsExecuting)
    {
        IsPaused = false;
        OnResumed();
    }
}

Здесь всё просто. Инструкцию можно поставить на паузу только в том случае, если она выполняется и если ещё не приостановлена, и продолжить выполнение, только если оно приостановлено.


public void Terminate()
{
    if (Stop())
    {
        OnTerminated();
        Terminate?.Invoke(this);
    }
}

private bool Stop()
{
    if (IsExecuting)
    {
        if (routine is Coroutine)
            Parent.StopCoroutine(routine as Coroutine);

        (this as IEnumerator).Reset();

        return IsStopped = true;
    }

    return false;
}

Основная логика остановки инструкций вынесена в метод Stop. Это нужно для того, чтобы иметь возможность выполнить тихую остановку (без вызова событий).
Проверка if (routine is Coroutine) необходима потому, что, как я писал выше, инструкция может быть запущена оператором yield (то есть без вызова StartCoroutine), а значит ссылки на конкретный экземпляр Coroutine может и не оказаться. В этом случае в routine будет лишь объект-заглушка.


public Instruction Execute(MonoBehaviour parent)
{
    if (current != null)
    {
        Debug.LogWarning($"Instruction { GetType().Name} is currently waiting for another one and can't be stared right now.");
        return this;
    }

    if (!IsExecuting)
    {
        IsExecuting = true;
        routine = (Parent = parent).StartCoroutine(this);

        return this;
    }

    Debug.LogWarning($"Instruction { GetType().Name} is already executing.");
    return this;
}

Основной метод запуска тоже крайне прост — запуск будет произведен только в том случае, если инструкция еще не запущена.


Осталось закончить реализацию IEnumerator, ведь мы оставили пустые места в некоторых методах.


IEnumerator.Reset()
{
    IsExecuting = false;
    IsPaused = false;
    IsStopped = false;

    routine = null;
}

И самое интересное, но от этого не более сложное, — MoveNext:


bool IEnumerator.MoveNext()
{
    if (IsStopped)
    {
        (this as IEnumerator).Reset();
        return false;
    }

    if (!IsExecuting)
    {
        IsExecuting = true;
        routine = new object();

        OnStarted();
        Started?.Invoke(this);
    }

    if (current != null)
        return true;

    if (IsPaused)
        return true;

    if (!Update())
    {
        OnDone();
        Done?.Invoke(this);

        IsStopped = true;
        return false;
    }

    return true;
}

if (!IsExecuting) — если инструкция не была запущена посредством StartCoroutine, и выполняется эта строка кода, значит её запустил оператор yield. Записываем в routine заглушку и выстреливаем событиями.


if (current != null)current используется для дочерних инструкций. Если вдруг такая объявилась, ждем ее окончания. Обратите внимание, процесс добавления поддержки дочерних инструкций в этой статье я упущу, чтобы не раздувать её еще больше. Потому, если дальнейшее добавление данного функционала вас не интересует, можете попросту убрать эти строки.


if (!Update()) — метод Update в наших инструкциях будет работать точно так же, как keepWaiting в CustomYieldInstruction и должен быть реализованным в дочернем классе. В Instruction это просто абстрактный метод.


protected abstract bool Update();

Код Instruction целиком
using UnityEngine;
using System;

using IEnumerator = System.Collections.IEnumerator;

public abstract class Instruction : IEnumerator, IInstruction
{
    private Instruction current;
    object IEnumerator.Current => current;

    private object routine;
    public MonoBehaviour Parent { get; private set; }

    public bool IsExecuting { get; private set; }
    public bool IsPaused { get; private set; }

    private bool IsStopped { get; set; }

    public event Action<Instruction> Started;
    public event Action<Instruction> Paused;
    public event Action<Instruction> Terminated;
    public event Action<Instruction> Done;

    void IEnumerator.Reset()
    {
        IsPaused = false;
        IsStopped = false;

        routine = null;
    }

    bool IEnumerator.MoveNext()
    {
        if (IsStopped)
        {
            (this as IEnumerator).Reset();
            return false;
        }

        if (!IsExecuting)
        {
            IsExecuting = true;
            routine = new object();

            OnStarted();
            Started?.Invoke(this);
        }

        if (current != null)
            return true;

        if (IsPaused)
            return true;

        if (!Update())
        {
            OnDone();
            Done?.Invoke(this);

            IsStopped = true;
            return false;
        }

        return true;
    }

    protected Instruction(MonoBehaviour parent) => Parent = parent;

    public void Pause()
    {
        if (IsExecuting && !IsPaused)
        {
            IsPaused = true;

            OnPaused();
            Paused?.Invoke(this);
        }
    }

    public void Resume()
    {
        IsPaused = false;
        OnResumed();
    }

    public void Terminate()
    {
        if (Stop())
        {
            OnTerminated();
            Terminated?.Invoke(this);
        }
    }

    private bool Stop()
    {
        if (IsExecuting)
        {
            if (routine is Coroutine)
                Parent.StopCoroutine(routine as Coroutine);

            (this as IEnumerator).Reset();

            return IsStopped = true;
        }

        return false;
    }

    public Instruction Execute()
    {
        if (current != null)
        {
            Debug.LogWarning($"Instruction { GetType().Name} is currently waiting for another one and can't be stared right now.");
            return this;
        }

        if (!IsExecuting)
        {
            IsExecuting = true;
            routine = Parent.StartCoroutine(this);

            return this;
        }

        Debug.LogWarning($"Instruction { GetType().Name} is already executing.");
        return this;
    }

    public Instruction Execute(MonoBehaviour parent)
    {
        if (current != null)
        {
            Debug.LogWarning($"Instruction { GetType().Name} is currently waiting for another one and can't be stared right now.");
            return this;
        }

        if (!IsExecuting)
        {
            IsExecuting = true;
            routine = (Parent = parent).StartCoroutine(this);

            return this;
        }

        Debug.LogWarning($"Instruction { GetType().Name} is already executing.");
        return this;
    }

    public void Reset()
    {
        Terminate();

        Started = null;
        Paused = null;
        Terminated = null;
        Done = null;
    }

    protected virtual void OnStarted() { }
    protected virtual void OnPaused() { }
    protected virtual void OnResumed() { }
    protected virtual void OnTerminated() { }
    protected virtual void OnDone() { }

    protected abstract bool Update();
}

Примеры


Базовый класс готов. Для создания любых инструкций достаточно наследовать его и реализовать необходимые члены. Предлагаю взглянуть на несколько примеров.


Передвижение к точке


public sealed class MoveToPoint : Instruction
{
    public Transform Transform { get; set; }
    public Vector3 Target { get; set; }
    public float Speed { get; set; }
    public float Threshold { get; set; }

    public MoveToPoint(MonoBehaviour parent) : base(parent) { }

    protected override bool Update()
    {
        Transform.position = Vector3.Lerp(Transform.position, Target, Time.deltaTime * Speed);

        return Vector3.Distance(Transform.position, Target) >= Threshold;
    }
}

Варианты запуска
private void Method()
{
    var move = new MoveToPoint(this)
    {
        Actor = transform,
        Target = target,
        Speed = 1.0F,
        Threshold = 0.05F
    };

    move.Execute();
}

private void Method()
{
    var move = new MoveToPoint(this);

    move.Execute(transform, target, 1.0F, 0.05F);
}

private IEnumerator Method()
{
    yield return new MoveToPoint(this)
    {
        Actor = transform,
        Target = target,
        Speed = 1.0F,
        Threshold = 0.05F
    };
}

private IEnumerator Method()
{
    var move = new MoveToPoint(this)
    {
        Actor = transform,
        Target = target,
        Speed = 1.0F,
        Threshold = 0.05F
    };

    yield return move;
}

private IEnumerator Method()
{
    var move = new MoveToPoint(this);

    yield return move.Execute(transform, target, 1.0F, 0.05F);
}

Обработка ввода


public sealed class WaitForKeyDown : Instruction
{
    public KeyCode Key { get; set; }

    protected override bool Update()
    {
        return !Input.GetKeyDown(Key);
    }

    public WaitForKeyDown(MonoBehaviour parent) : base(parent) { }

    public Instruction Execute(KeyCode key)
    {
        Key = key;

        return base.Execute();
    }
}

Примеры запуска
private void Method()
{
    car wait = new WaitForKeyDown(this);

    wait.Execute(KeyCode.Space).Done += (i) => DoSomething();
}

private IEnumerator Method()
{
    yield return new WaitForKeyDown(this) { Key = KeyCode.Space };

    // do something;
}

Ожидание и пауза


public sealed class Wait : Instruction
{
    public float Delay { get; set; }
    private float startTime;

    protected override bool Update()
    {
        return Time.time - startTime < Delay;
    }

    public Wait(MonoBehaviour parent) : base(parent) { }

    public Wait(float delay, MonoBehaviour parent) : base(parent)
    {
        Delay = delay;
    }

    protected override void OnStarted()
    {
        startTime = Time.time;
    }

    public Instruction Execute(float delay)
    {
        Delay = delay;

        return base.Execute();
    }
}

Здесь, казалось бы, всё максимально просто. Вначале записали время запуска, а потом проверяем разницу между текущим временем и StartTime. Но есть один нюанс, который можно сразу и не заметить. Если во время выполнения инструкции вы приостановите её (методом Pause) и подождёте, то после возобновления (Resume) она будет моментально выполнена. А всё потому, что сейчас эта инструкция не учитывает, что её могу поставить на паузу.


Попробуем исправить эту несправедливость:


protected override void OnPaused()
{
    Delay -= Time.time - startTime;
}

protected override void OnResumed()
{
    startTime = Time.time;
}

Готово. Теперь, после окончания паузы, инструкция будет продолжать выполняться столько времени, сколько оставалось до её начала.


Примеры запуска
private void Method()
{
    var wait = new Wait(this);

    wait.Execute(5.0F).Done += (i) => DoSomething;
}

private IEnumerator Method()
{
    yield return new Wait(this, 5.0F);

    // do something;
}

Итог


Корутины в Unity позволяют делать удивительные и функциональные вещи. А вот насколько комплексными они будут и нужны ли они вам вообще — личный выбор каждого.


Расширение функционала корутин — задача не самая сложная. Всё, что вам необходимо, — решить, какой интерфейс взаимодействия с ними вы хотите иметь, и каким будет алгоритм их работы.


Спасибо за ваше время. Оставляйте свои вопросы/замечания/дополнения в комментариях. Буду рад общению.




P.S. Если, внезапно, у вас подгорело по поводу использования корутин, прочтите этот комментарий и выпейте чего-нибудь прохладного.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (4):

  1. jff
    /#19867734

    Раньше тоже пытался улучшить коротуны под себя в Unity. Но потом открыл для себя UniRx.

    • RustySword
      /#19867764

      Да там особо и нечего пытаться. Подавляющее большинство функционала можно реализовать на правильно приготовленной связке паттерна Command и компонентного подхода, без всяких корутин, UniRx и прочего мракобесия.

  2. PSG_Developer
    /#19872658

    Большую часть идей модно реализовать через корутины, что уже есть. Статья интересная, но создавать свою систему аналогичную готовой — это такое себе…

    • RustySword
      /#19872662

      Подавляющее большинство функционала можно реализовать на правильно приготовленной связке паттерна Command и компонентного подхода, без всяких корутин, UniRx и прочего мракобесия.
      Это раз.

      «Уже готовые» корутины очень далеки от того, что предложено в статье – это два. Иначе извольте предоставить примеры «аналогичности», о который вы говорите.