複数ループDOTweenのタイミングを同期する方法

## はじめに
[サムザップ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/sumzap) の12/8の記事です。

株式会社サムザップ Unityエンジニアの尾崎です。

Unityの[DOTween](http://dotween.demigiant.com/)で複数のループトゥイーンのタイミングを同期する方法について紹介します。

### DOTweenについて
Unityでトゥイーンアニメーションを行うためのライブラリです。
無償利用することができます。

公式サイト
http://dotween.demigiant.com/

GitHub
https://github.com/Demigiant/dotween

## 内容
UnityUIで複数のオブジェクトを表示し、オブジェクトをクリックしたときに選択カーソルを表示するケースを想定してみます。
そしてその選択カーソルにDOTweenのループトゥイーンでアニメーションを付けます。

アニメーションのタイミングの同期を行わずにクリックしたタイミングでループトゥイーンを開始するとオブジェクトごとにアニメーションが動き、違和感のある見た目になってしまいます。

すべての選択カーソルのアニメーションタイミングが同期していると見た目が良くなります。

| 同期なし | 同期あり |
|:------:|:------:|
| ![no_sync.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203221/47769454-bab8-4977-376e-f8f43051c09b.gif) | ![sync.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203221/d62ef9b2-fbfe-aec4-32b6-41b95bcf36ed.gif) |

### 同期する方法
再生しようとしているトゥイーンとアニメーション時間(Duration)が同じ再生中のトゥイーンがあれば、再生位置[(position)](https://github.com/Demigiant/dotween/blob/develop/_DOTween.Assembly/DOTween/Tween.cs#L114)を同期して再生開始します。
再生位置を移動させるには[GoToメソッド](https://github.com/Demigiant/dotween/blob/develop/_DOTween.Assembly/DOTween/TweenExtensions.cs#L101)を実行します。

これらを活用したユーティリティクラスと拡張メソッドを作成しました。
以下のようなコードでタイミングを同期して再生できるようになります。

```csharp
// ループトゥイーン作成
var tween = transform.DOFade(0f, 1f).SetLoops(-1, LoopType.Yoyo)
// 他のトゥイーンと再生タイミング同期
tween.SyncWithPrimary();
// 再生開始
tween.Play();
// 同期用に登録
tween.RegisterForSync();
```
ユーティリティクラスと拡張メソッドのソースコードを紹介します。

## ユーティリティクラスと拡張メソッドのソースコード
タイミング同期を行うためのクラスです

```csharp:DOTweenSynchronizer.cs
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using DG.Tweening;

/// <summary>
/// DOTweenの再生タイミングを同期
/// </summary>
public static class DOTweenSynchronizer
{
    private static Dictionary<string, HashSet<Tween>> _tweensDict;

    static DOTweenSynchronizer()
    {
        _tweensDict = new Dictionary<string, HashSet<Tween>>();
    }

    /// <summary>
    /// 登録
    /// 同期キーを指定しない場合トゥイーンのdurationが同じものが同期対象になります
    /// トゥイーンターゲットのGameObjectがアクティブなものが同期対象になるのでSetTarget()でのターゲット指定を推奨
    /// </summary>
    /// <param name="tween">対象トゥイーン</param>
    /// <param name="key">同期キー</param>
    /// <param name="autoUnregister">Killしたときに自動的に同期対象から登録解除</param>
    /// <returns>Tween</returns>
    public static Tween Register(Tween tween, string key = null, bool autoUnregister = true)
    {
        if (tween == null || !tween.active) return tween;

        if (string.IsNullOrEmpty(key))
        {
            key = GetDefaultSyncKey(tween);
        }

        HashSet<Tween> tweens;
        if (!_tweensDict.TryGetValue(key, out tweens))
        {
            tweens = new HashSet<Tween>();
            _tweensDict.Add(key, tweens);
        }
        tweens.Add(tween);

        // Killで同期対象から解除
        if (autoUnregister)
        {
            tween.OnKill(() => Unregister(tween));
        }

        return tween;
    }

    /// <summary>
    /// 解除
    /// </summary>
    /// <param name="tween">対象トゥイーン</param>
    /// <param name="key">同期キー</param>
    public static void Unregister(Tween tween, string key = null)
    {
        if (tween == null) return;

        if (tween.active)
        {
            if (string.IsNullOrEmpty(key))
            {
                key = GetDefaultSyncKey(tween);
            }

            if (_tweensDict.TryGetValue(key, out var tweens))
            {
                tweens.Remove(tween);

                // 0件になったHashSetは削除
                if (tweens.Count == 0)
                {
                    _tweensDict.Remove(key);
                }
            }
        }
        else
        {
            // tweenがKillされている場合はデフォルトキーが作れないので全件走査で削除
            foreach (var set in _tweensDict.Values)
            {
                set.Remove(tween);
            }

            // 0件になったHashSet削除
            var emptyTweens = _tweensDict.Where(kvp => kvp.Value.Count == 0);
            foreach (var kvp in emptyTweens.Reverse())
            {
                _tweensDict.Remove(kvp.Key);
            }
        }
    }

    /// <summary>
    /// デフォルトの同期キーを取得
    /// トゥイーン時間が同じものを同期対象にする
    /// </summary>
    /// <param name="tween"></param>
    /// <returns>同期キー</returns>
    private static string GetDefaultSyncKey(Tween tween)
    {
        if (tween == null || !tween.active) return null;

        float duration = tween.Duration(false);
        if (Mathf.Approximately(duration, 0f))
        {
            Debug.LogWarning("Durationが0のTweenが指定されました。");
        }
        return $"duration_{duration}";
    }

    /// <summary>
    /// 第一トゥイーンと同期
    /// </summary>
    /// <param name="tween">対象トゥイーン</param>
    /// <param name="key">同期キー</param>
    /// <returns></returns>
    public static Tween SyncWithPrimary(Tween tween, string key = null)
    {
        // 同期トゥイーンがないときはスキップ
        if (_tweensDict.Count == 0) return tween;

        // 同期キーが指定されないときはデフォルトキー (トゥイーン時間ベース)
        if (string.IsNullOrEmpty(key))
        {
            key = GetDefaultSyncKey(tween);
        }

        // 同期キーが該当なしならスキップ
        if (!_tweensDict.ContainsKey(key)) return tween;

        // 他の再生中のトゥイーン一覧
        var tweens = _tweensDict.GetValueOrDefault(key);

        // 第一トゥイーンを検索
        Tween first = tweens.FirstOrDefault(x =>
            // 同期対象は除く
            x != tween
            // アクティブ (Killされていない)
            && x.active
            // 再生中
            && x.IsPlaying()
            // トゥイーン対象がGameObjectでHierarchyでアクティブ
            && ((x.target as GameObject)?.activeInHierarchy ?? true)
            // トゥイーン対象がコンポーネントでそのGameObjectがHierarchyでアクティブ
            && ((x.target as Component)?.gameObject?.activeInHierarchy ?? true)
        );

        if (first == null) return tween;

        // 再生開始位置
        float position = 0f;

        // 再生中トゥイーンの位置を開始位置に指定
        position = first.position;

        // Yoyoループの逆再生中?
        if (tween.hasLoops && first.IsYoyoBackwards())
        {
            // Yoyoの逆再生中のpositionは逆再生の開始点からの値なので順再生分の秒数を加算
            position += tween.Duration(false);
        }
        // 再生位置設定
        tween.Goto(position);

        return tween;
    }

    /// <summary>
    /// クリア
    /// </summary>
    public static void Clear()
    {
        if (_tweensDict.Count == 0) return;

        foreach (var v in _tweensDict.Values)
        {
            v.Clear();
        }
        _tweensDict.Clear();
    }
}

/// <summary>
/// DOTweenSynchronizer用拡張メソッド
/// </summary>
public static class DOTweenSynchronizerExtensions
{
    /// <summary>
    /// 同期のために登録
    /// </summary>
    /// <param name="tween"></param>
    /// <param name="syncKey">同期キー</param>
    /// <param name="autoUnregister">Killしたときに自動的に同期対象から登録解除</param>
    /// <returns></returns>
    public static Tween RegisterForSync(this Tween tween, string syncKey = null, bool autoUnregister = true)
        => DOTweenSynchronizer.Register(tween, syncKey);

    /// <summary>
    /// 同期するための登録解除
    /// TweenをKillする前に実行してください
    /// </summary>
    /// <param name="tween"></param>
    /// <param name="syncKey">同期キー</param>
    /// <returns></returns>
    public static void UnregisterForSync(this Tween tween, string syncKey = null)
        => DOTweenSynchronizer.Unregister(tween, syncKey);

    /// <summary>
    /// 第一トゥイーンと同期する
    /// </summary>
    /// <param name="tween"></param>
    /// <param name="syncKey">同期キー</param>
    /// <returns></returns>
    public static Tween SyncWithPrimary(this Tween tween, string syncKey = null)
        => DOTweenSynchronizer.SyncWithPrimary(tween, syncKey);

    /// <summary>
    /// Yoyoの逆再生中？
    /// </summary>
    /// <param name="tween"></param>
    /// <returns></returns>
    public static bool IsYoyoBackwards(this Tween tween)
    {
        if (!tween.hasLoops) return false;
        return !Mathf.Approximately(tween.ElapsedPercentage(false), tween.ElapsedDirectionalPercentage());
    }
}
```

### 使い方のサンプルコード

```csharp
private Tween _tween;

void OnEnable()
{
    // 点滅するループトゥイーン作成
     _tween = transform.DOFade(0f, 1f).SetLoops(-1, LoopType.Yoyo)
    
    // 他のトゥイーンと再生タイミング同期
    _tween.SyncWithPrimary();

    // 同期した位置から再生
    _tween.Play();
    
    // 同期用に登録 (Killで自動的に登録解除される)
    _tween.RegisterForSync();
}

void OnDisable()
{
    _tween.Rewind();
    _tween.Kill();
    _tween = null;
}
```
#### メソッドチェーンでの記述
DOTweenの各種メソッドにつなげて記述することもできます。
```csharp
_tween = transform.DOFade(0f, 1f)
    .SetLoops(-1, LoopType.Yoyo)
    .SyncWithPrimary()
    .RegisterForSync()
    .Play();
```

## 動作環境
Unity2021.3

## 最後に
UnityでDOTweenを使うときのTipsを紹介しました。お役に立てれば嬉しく思います。

明日は @Gaku_Ishii さんの記事です。