【Unity】PlayableDirectorをまともに動かすためのラッパークラスを書いた【Timeline】

# はじめに
Timelineは便利だけど、Timelineを再生するための `PlayableDirector` をスクリプトから操作しようと思ったら挙動がいろいろ変だったりAPIが貧弱だったりして困ったので、まともに動かすためのラッパークラスを書いた。

デフォルトの「`Play On Awake` にチェック入れて自動で再生」とかやってる分には全然問題ないんだけど、スクリプトから触ろうと思った途端に発狂しそうになるはずなので、発狂する前にこの記事を思い出してください。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">・再生完了とStop()のイベントはどっちもstopped<br>　- 見分けがつかない<br>・状態が２つしかない<br>　- Paused,Playing<br>・APIが少ない、使いづらい<br><br>こんなところかな</p>&mdash; su10@ハイパーカジュアルゲーム開発 (@su10_dev) <a href="https://twitter.com/su10_dev/status/1292477035135119360?ref_src=twsrc%5Etfw">August 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

特に `stopped` が呼ばれなかったりポーズと停止の見分けがつかないのが辛い。

自分がなんじゃこりゃとなったのはUnity2018.4.21f1なので、最新のUnityだとまともになってるかもしれない。

# `PlayableDirectorPlayer`

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">書いたラッパーは<br><br>・状態を３つにした<br>　- Stopped,Playing,Paused<br>・イベント増やした<br>　- start,play,kill,complete,pause,stepComplete<br>　- イベントが呼ばれる仕様はDOTweenに寄せた<br>・API増やした<br>　- normalizedTime<br>　- timeScale<br>　- ループ回数<br><br>こんな感じ。複雑な状態管理が難しかった。</p>&mdash; su10@ハイパーカジュアルゲーム開発 (@su10_dev) <a href="https://twitter.com/su10_dev/status/1292478607076388864?ref_src=twsrc%5Etfw">August 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

```PlayableDirectorPlayer.cs
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UniRx;
using UnityEngine;
using UnityEngine.Playables;

public class PlayableDirectorPlayer : MonoBehaviour
{
    private enum PlayState
    {
        Stopped,
        Playing,
        Paused,
    }

    [SerializeField] private PlayableDirector _director;
    [SerializeField] private float _initialTime;

    public float initialTime
    {
        get => _initialTime;
        set
        {
            _initialTime = Mathf.Clamp(value, 0, float.MaxValue);
            _director.initialTime = this.initialTime;
        }
    }

    [SerializeField]
    private float _timeScale = 1;

    public float timeScale
    {
        get => _timeScale;
        set
        {
            _timeScale = Mathf.Clamp(value, 0, float.MaxValue);

            if (this.playableGraph.IsValid())
            {
                this.playableGraph.GetRootPlayable(0).SetSpeed(this.timeScale);
            }
        }
    }

    private int _completedLoops;
    public int CompletedLoops() => _completedLoops;

    public double time
    {
        get => _director.time;
        set
        {
            if (this.extrapolationMode == DirectorWrapMode.Loop)
            {
                _completedLoops = (int) (value / this.duration);
                _director.time = value - (this.duration * _completedLoops);
            }
            else
            {
                if (value < 0)
                {
                    _director.time = 0;
                }
                else if (this.duration <= value)
                {
                    _director.time = this.duration;
                }
                else
                {
                    _director.time = value;
                }
            }

            _director.Evaluate();

            if (_state == PlayState.Stopped)
            {
                if (this.extrapolationMode == DirectorWrapMode.Hold)
                {
                    var timeCache = this.time;
                    _director.Stop();
                    _director.time = timeCache;
                }

                _state = PlayState.Paused;
            }
        }
    }

    public double normalizedTime
    {
        get => this.time / this.duration;
        set => this.time = value * this.duration;
    }

    public double duration => _director.duration;

    private PlayState _state;
    public bool IsStopped => (_state == PlayState.Stopped);
    public bool IsPlaying => (_state == PlayState.Playing);
    public bool IsPaused => (_state == PlayState.Paused);

    public DirectorWrapMode extrapolationMode => _director.extrapolationMode;
    public PlayableGraph playableGraph => _director.playableGraph;

    private readonly Subject<Unit> _start = new Subject<Unit>();
    private readonly Subject<Unit> _play = new Subject<Unit>();
    private readonly Subject<Unit> _pause = new Subject<Unit>();
    private readonly Subject<Unit> _stepComplete = new Subject<Unit>();
    private readonly Subject<Unit> _complete = new Subject<Unit>();
    private readonly Subject<Unit> _kill = new Subject<Unit>();

    public IObservable<Unit> OnStartAsObservable() => _start;
    public IObservable<Unit> OnPlayAsObservable() => _play;
    public IObservable<Unit> OnPauseAsObservable() => _pause;
    public IObservable<Unit> OnStepCompleteAsObservable() => _stepComplete;
    public IObservable<Unit> OnCompleteAsObservable() => _complete;
    public IObservable<Unit> OnKillAsObservable() => _kill;

    void OnDestroy()
    {
        _start.OnCompleted();
        _start.Dispose();

        _play.OnCompleted();
        _play.Dispose();

        _pause.OnCompleted();
        _pause.Dispose();

        _stepComplete.OnCompleted();
        _stepComplete.Dispose();

        _complete.OnCompleted();
        _complete.Dispose();

        _kill.OnCompleted();
        _kill.Dispose();
    }

    void Start()
    {
        if (_director.state == UnityEngine.Playables.PlayState.Playing)
        {
            _state = PlayState.Playing;
            this.timeScale = this.timeScale;

            _start.OnNext(Unit.Default);
            _play.OnNext(Unit.Default);
        }

        // 再生完了のチェック
        {
            // Wrap Mode(extrapolationMode)がNoneの場合
            _director.stopped += _ =>
            {
                if (_state != PlayState.Playing) return;

                _state = PlayState.Stopped;
                _completedLoops = 1;
                _stepComplete.OnNext(Unit.Default);
                _complete.OnNext(Unit.Default);
                _kill.OnNext(Unit.Default);
            };

            // Hold/Loopの場合
            this.CheckCompleteTask(this.GetCancellationTokenOnDestroy()).Forget();
        }
    }

    private async UniTaskVoid CheckCompleteTask(CancellationToken cancellationToken)
    {
        while (cancellationToken.IsCancellationRequested == false)
        {
            {
                await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
                var prevTime = this.time;

                await UniTask.Yield(PlayerLoopTiming.LastUpdate, cancellationToken);
                var currentTime = this.time;

                if ((this.extrapolationMode == DirectorWrapMode.Loop) && currentTime < prevTime)
                {
                    _completedLoops++;
                    _stepComplete.OnNext(Unit.Default);
                }
            }

            if (this.playableGraph.IsValid() == false) continue;
            if (this.extrapolationMode != DirectorWrapMode.Hold) continue;
            if (this.time < this.duration) continue;
            if (_state == PlayState.Stopped) continue;

            _state = PlayState.Stopped;
            _completedLoops = 1;
            _stepComplete.OnNext(Unit.Default);
            _complete.OnNext(Unit.Default);
            _kill.OnNext(Unit.Default);
        }
    }

    public void Play()
    {
        if (_state == PlayState.Playing) return;

        switch (_state)
        {
            case PlayState.Stopped:
            {
                _completedLoops = 0;
                _director.time = this.initialTime;
                _director.Evaluate();
                break;
            }
            case PlayState.Playing: return;
            case PlayState.Paused:
            {
                _director.Evaluate();
                break;
            }
            default: throw new ArgumentOutOfRangeException();
        }

        var prevState = _state;
        _state = PlayState.Playing;

        _director.Play();
        this.timeScale = this.timeScale;

        if (prevState == PlayState.Stopped)
        {
            _start.OnNext(Unit.Default);
        }

        _play.OnNext(Unit.Default);
    }

    public void Pause()
    {
        if (_state != PlayState.Playing) return;
        _state = PlayState.Paused;

        var timeCache = this.time;
        _director.Stop();
        _director.time = timeCache;

        _pause.OnNext(Unit.Default);
    }

    public void Kill(bool complete = false)
    {
        if (_state == PlayState.Stopped) return;

        _state = PlayState.Stopped;

        if (complete && (this.extrapolationMode != DirectorWrapMode.Loop))
        {
            _director.Stop();
            _director.time = _director.duration;
            _director.Evaluate();

            _completedLoops = 1;
            _complete.OnNext(Unit.Default);
        }
        else
        {
            var timeCache = this.time;
            _director.Stop();
            _director.time = timeCache;
        }

        _kill.OnNext(Unit.Default);
    }

    public void Complete()
    {
        if (_director.extrapolationMode == DirectorWrapMode.Loop) return;
        if (_state == PlayState.Stopped) return;
        _state = PlayState.Stopped;

        _director.Stop();
        _director.time = _director.duration;
        _director.Evaluate();

        _completedLoops = 1;
        _complete.OnNext(Unit.Default);
        _kill.OnNext(Unit.Default);
    }
}
```

UniRxとUniTaskを使っていて、UniRxを使っている部分はC#のdelegateとかで代用可能。

UniTaskは `PlayableDirector` の再生完了の監視に使っていて、完了イベントの発火タイミングが厳密でなくていいならコルーチンとかに置き換え可能。

# 参考

* [How to know when a timeline is complete ?](https://forum.unity.com/threads/how-to-know-when-a-timeline-is-complete.493058/)（Unityフォーラムの質問）
* [UnityのTimelineをいくらか理解する](https://framesynthesis.jp/blog/2018/06/18/)
* [PlayableDirectorの再生が終わると状態がリセットされる場合](https://qiita.com/Teach/items/36aab36783c03992706c)