UnityのTimelineで汎用的な行動再生機能[ActionPlayer]を実装してみた

# 概要
2Dベルトアクションゲームでキャラクターの通常攻撃などの行動をなるべくUnityに元からある機能を利用してEditor上で作れるようにすると工数削減になり理想です。
そこで利用するのがTimelineとCustomTrackです。通常攻撃などあらゆる行動はいろいろな機能の組み合わせで構成されます。モーションの再生、攻撃判定の発生、エフェクトの発生、キャンセルの可否、等々。
それぞれの機能をCustomTrackで簡単に実装できるようにし、この組み合わせでキャラクターの行動を簡単に作れたという思想でActionPlayerを実装してみました。
またCustomTrackについては実装方法など既に色々記事あるので詳しく知りたい方は調べて見てください。


# 環境
- Unity 6000.0.23f1

# 実装

### 実装するクラス、インターフェイス
- ActionPlayer
基幹となるクラス。PlayableDirectorにTimelineAssetを渡し、それぞれの機能の初期化したりする。
- ActionAsset
キャラクターの行動をCustomTrackで作ったTimelineAssetを持ったAsset。
- CustomTrack
モーションの再生、攻撃判定の発生、エフェクトの発生、キャンセルの可否、等々のカスタムトラック。
- 機能ごとのInterface
CustomTrackからの制御を渡すためのInterface。

### ActionPlayer
Timelineの初期化。行動の再生、停止をします。また操作キャラと敵キャラで使う機能が別なので、それぞれ派生クラスを用意しました。

```C#:ActionPlayer
//MonoBehaviourを継承してComponentとしてアタッチするようにし、
//PlayableDirectorも同じGameobjectにアタッチされるようにする
[RequireComponent(typeof(PlayableDirector))]
public abstract class ActionPlayer : MonoBehaviour
{
// ActionAssetは前もってアタッチしておく仕様
    [SerializeField] private List<ActionAsset> _actionAssetList;
    protected Dictionary<string, ActionAsset> _actionAssets;
    protected PlayableDirector _playableDirector;
    private bool _isAction;
    private float time;
    private ILoop _loop;
    public string PlayingActionName {get; private set;}

    public UnityAction Canceled;
    public UnityAction Completed;
    public bool IsAction {get => _isAction;}
//初期化処理
    protected void Init(IMotionState motionState, ILoop loop, IActionMove actionMove, IHitCircle hitCircle)
    {
        _loop = loop;
        _playableDirector = GetComponent<PlayableDirector>();
//時間経過はバレットタイムやヒットストップの実装も考えて自前で進めるためManual　
        _playableDirector.timeUpdateMode = DirectorUpdateMode.Manual;
//ActionAssetを再生するとき名前で再生したいのでKeyを名前にしたDictionaryに変換
        if (_actionAssetList.Count > 0)
        {
            _actionAssets = _actionAssetList.ToDictionary(_ => _.name, _ => _);            
        }
        else return;
//CustomTrackに紐づかせたいInterfaceを前もってセット
//（Trackに紐づくObjectの情報はPlayableDirectorで管理してる）
        _playableDirector.SetReferenceValue(ActionPlayerConst.motionStateName, motionState.Self);
        _playableDirector.SetReferenceValue(ActionPlayerConst.loopName, loop.Self);
        _playableDirector.SetReferenceValue(ActionPlayerConst.moveName, actionMove.Self);
        _playableDirector.SetReferenceValue(ActionPlayerConst.hitCircleName, hitCircle.Self);
    }

// 自前で時間経過させるための処理。Updateなどで毎フレーム呼ぶ。
    public void Tick(float deltaTime)
    {
        if (_playableDirector && _playableDirector.playableAsset)
        {
            if (time < _playableDirector.duration)
            {
                _playableDirector.time = time;
                _playableDirector.Evaluate();
            }
            else if (time > _playableDirector.duration)
            {
                _playableDirector.time = _playableDirector.duration;
                _playableDirector.Evaluate();
                if (_loop.IsLoop)
                {
                    _playableDirector.Stop();
                }
                else
                {
                    _playableDirector.playableAsset = null;
                    Completed?.Invoke();
                    StopAction();
                }
            }

            
            time += deltaTime;
        }
    }
    //ILoopのループ用
    public void SetLoop(float startTime)
    {
        _playableDirector.time = startTime;
        time = startTime;
        _playableDirector.Evaluate();
        _playableDirector.Play();
    }
// 再生時はActionAssetの名前で再生する   
    protected void PlayAction(string actionName)
    {
        time = 0;
        _playableDirector.playableAsset = _actionAssets[actionName].timelineAsset;
        PlayingActionName = actionName;
        _isAction = true;
    }

    protected void CancelAction()
    {
        Canceled?.Invoke();
        StopAction();
    }

    protected void StopAction()
    {
        time = 0;
        _playableDirector.playableAsset = null;
        PlayingActionName = "";
        _isAction = false;
    }
}
```

### ActionAsset
キャラクターの行動を制御するTimelineAssetなどを持ったAsset。ActionPlayerにアタッチしといて利用する。

```C#:ActionAsset
// AssetなのでScriptableObjectを継承する

[CreateAssetMenu(fileName = "ActionAsset", menuName = "Scriptable Objects/ActionAsset")]
public class ActionAsset : ScriptableObject
{
    public TimelineAsset timelineAsset;
    public EnemyShootPositionType shootPositionType;
    public GameObject Effects;
}
```


**通常攻撃初段のTimelineAsset**
上四つが自前で用意したCustomTrackで上から
どのモーションを再生するかの設定、
どのステートを受付けてどの行動でキャンセルできどの行動に移行できるかをClipごとに設定、
[剣閃エフェクト](https://qiita.com/dieblondebestie/items/3e2a2414a0806df17518)をどのくらいの強さで発生させるかの設定、
攻撃判定のでる期間の設定
となっている。
下二つは普通のAnimationTrackで剣閃エフェクトの発生位置と攻撃判定のコライダーをアニメーションさせるTrack。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4054619/8a11c412-53c4-4992-99a6-17c285ddf1bf.png)

### CustomTrackとInterface
詳しくは説明しませんがCustomTrackを実装する上で基本的に４つのものを用意する必要があります。
- Track
タイムライン上に追加できる行のアセット。中に指定したクリップを配置できる。
- Clip
Track 上に配置できる範囲を持つアセット。
- Playable Behaviour
Clip のふるまい
- Mixer
複数の Clip（Playable Behaviour）のブレンド
このうちMixerの処理は重くなりがちなので、単体のClipだけのふるまいだけで完結するようにしMixerは省きました。

**一例としてキャンセルの挙動を制御するCancelTrackを紹介します。**

```C#:CancelTrack
//Mixerないのでこれだけ
[TrackClipType(typeof(CancelClip))]
public class CancelTrack : TrackAsset
{
}
```
```C#:CancelClip
public class CancelClip : PlayableAsset, ITimelineClipAsset
{
// PlayableBehaviourを作成
    public CancelBehaviour template = new CancelBehaviour();
// Clipの重なりの設定。基本重ならない設定
    public ClipCaps clipCaps => ClipCaps.None;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
// ActionPlayerで紐づかせたInterfaceを取得
        var cancelStateObject = graph.GetResolver().GetReferenceValue(ActionPlayerConst.cancelStateName, out var isValid) as GameObject;
// PlayableBehaviourの初期化
        if (isValid)
            template.Init(cancelStateObject.GetComponent<ICancelState>());
        return ScriptPlayable<CancelBehaviour>.Create(graph, template);
    }
}
```
```C#:CancelBehaviour
[Serializable]
public class CancelBehaviour : PlayableBehaviour
{
    private ICancelState _cancelState;
    [Header("アクション中受け付けるステート\n（先行入力する場合はそのステートを前クリップから設定しておく）")]
    public PlayableCharacterCancelStates cancelStates;
    [Header("アクションを中止するステート\n（cancelStatesで受付可能にしておく）")]
    public PlayableCharacterStates escapeStates;
    [Header("移行するアクション\n（cancelStatesで受付可能にしておく）")]
    public PlayableActionState transferState;

    public void Init(ICancelState cancelState)
    {
        _cancelState = cancelState;
    }
//Timelineの経過時間がClipの最初に来た時呼ばれる
    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        if (_cancelState == null) return;
        _cancelState.SetCancelState(cancelStates);
    }
//Timelineの経過時間がClip上にある時毎フレーム呼ばれる
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        if (_cancelState == null) return;
        _cancelState.SetEscapeState(escapeStates);
        _cancelState.SetTransferState(transferState);
    }
}
```
```C#:ICancelState
public interface ICancelState
{
// PlayableDirectorにInterface渡すとき、Objectじゃないと渡せないので
// Interfaceを実装しているGameObjectを渡して、取得するときGetComponentで再度Interfaceを取得する
// なのでInterfaceの実装先はMonoBehaviourを継承してないといけないという制限が付く
    GameObject Self {get;}
    void SetCancelState(PlayableCharacterCancelStates cancelStates);
    void SetEscapeState(PlayableCharacterStates escapeStates);
    void SetTransferState(PlayableActionState actionState);
}
```

ICancelStateの実装先でSetTransferState呼ばれた際にActionPlayerのまた再生するようにすれば、CancelTrack内のClipで設定したタイミングでキャンセルして次の行動をできるようになる。

# まとめ
キャラクターの行動をTimelimeを使用してEditor上で作成できるようにすることで、必要な機能のTrackのみを追加して作れるのでより感覚的に作れます。
また非再生時でも動くように実装すればTimelineでシークバー動かしながら挙動確認しつつ作れるので効率的な作業が望めます。
また拡張性も高いのでこういう事例に限らずTimelineは色々使いどころがあって便利かなと思います。