Tweenを簡単に自作してみる

# はじめに
これはLerp関数を最近知った筆者がTweenを自作してみた話

## Tweenとは？
始点、終点、間の挙動を指定することで、間を補間してアニメーションさせることのできるライブラリです。
Unityだと DOTween[^DOTween] や、 MagicTween[^MagicTween] などが有名です。

[^DOTween]: https://assetstore.unity.com/packages/tools/animation/dotween-hotween-v2-27676
[^MagicTween]: https://github.com/AnnulusGames/MagicTween/blob/main/README_JA.md

Tweenを使用すると、アニメーションを簡潔に実装できます。
次のコードはDOTweenを使用してx軸正方向に1だけ2秒かけて移動する処理を実装したものです。
```csharp:DOTweenの使用例
transform.DOMoveX(transform.position.x + 1, 2f);
```


# Tweenの自作

実際に作っていきます。

## Lerp関数を使ってUpdate関数内に実装する

### Lerp関数とは？
この自作Tweenの一番の要はLerp関数です。

`Lerp(a, b, t)`とは、始点`a`, 終点`b` としたときの割合`t`だけ進んだ値を返す関数で、グラフでは下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/b35f85d3-23d0-ffdc-2e40-a97b9ccf9d19.png)


例えば
`a=1, b=5, t=0.5`のとき、`1` から `5` までの道を `50%` 進んだ点なので `Lerp(1, 5, 0.5) = 3` となります。
`a=4, b=8, t=0`のとき、`4` から `8` までの道を `0%` 進んだ点なので `Lerp(4, 8, 0) = 4` となります。

### Lerp関数を使用して`deltaTime`経過時の位置を求める

`dulation`秒かけて`a`から`b`まで同じ速度で移動するとき、
移動開始からの経過時間を`deltaTime`とすると、

`deltaTime`経過時の位置は **`Lerp(a, b, deltaTime/duration)`** と表せます。

:::note info
deltaTime = 0 のとき、deltaTime/duration = 0%
deltaTime = duration/2 のとき、deltaTime/duration = 50%
deltaTime = duration のとき、deltaTime/duration = 100%
のようになる。
:::

### Update関数内にTweenを実装する

前節の計算を `deltaTime` が `duration` を超えるまで繰り返せば良いため、
Update関数内に次のコードを書くことで、始点から終点までを指定時間かけて等速で移動します。
下のコードでは、 `Vector3` に用意された Lerp を使用しています。

```csharp:TweenTest.cs
using UnityEngine;

public class TweenTest : MonoBehaviour
{
    // Tween開始からの経過時間
    float deltaTime = 0f;
    // Tweenにかける時間
    float duration = 2f;

    // 移動の開始位置と終了位置
    Vector3 startPos = new Vector3(-3, 0, 0);
    Vector3 endPos = new Vector3(3, 0, 0);
    
    void Start()
    {
        // Tweenを開始するのでdeltaTimeを0にする
        deltaTime = 0f;
    }

    void Update()
    {
        // 経過時間を足す
        deltaTime += Time.deltaTime;

        Vector3 pos = Vector3.Lerp(startPos, endPos, deltaTime/duration);
        transform.position = pos;
    }
}
```

実際に上のスクリプトをアタッチして動かしてみるとこんな感じ

![Linear Tween Circle.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/7ebdabeb-46ea-da46-9267-5fe238b4cdb0.gif)


## Taskを使用してTweenを実装する

前章では、Lerpを使用してUpdate関数内にTweeenを実装してみました。
今章ではTweenを使いまわせるように`Task`を使って関数にします。

### Task内で経過時間を取得するには？

`Time.deltaTiime` で取得できる経過時間は前回のフレームからの時間です。
Update関数はフレーム毎に呼ばれていたため、

`deltaTime += Time.deltaTime;`

と書くだけで経過時間を計算できました。

それに対して`Task`ではループの中で 1フレーム待つ必要があるため、 `await Task.Yield();` を使用します。

次のコードは`Task`を使用して実装したTweenと、その使用例です。

```csharp:MyTween.cs
using System.Threading.Tasks;
using UnityEngine;

public static class MyTween
{
    public static async Task TweenPosition(Transform transform, Vector3 startPos, Vector3 endPos, float dulation)
    {
        float deltaTime = 0f;

        Vector3 pos;

        while (deltaTime <= duration)
        {
            // 1フレームの間の秒数を足す
            deltaTime += Time.deltaTime;

            // 位置を求める
            pos = Vector3.Lerp(startPos, endPos, deltaTime/duration);
            
            // 位置を更新
            transform.position = pos;

            // 1フレーム待つ
            await Task.Yield();
        }
    }
}
```

```csharp:TweenTest.cs
using UnityEngine;

public class TweenTest : MonoBehaviour
{
    // Tweenにかける時間
    float duration = 2f;

    // 移動の開始位置と終了位置
    Vector3 startPos = new Vector3(-3, 0, 0);
    Vector3 endPos = new Vector3(3, 0, 0);
    
    void Start()
    {
        // Tweenを開始する
        MyTween.TweenPosition(transform, startPos, endPos, duration);
    }
}
```

実際に動かしてみると、Update関数内に実装した時と同じように動くことが確認できます。

### Task実行時の警告を消す

現時点だと`Task`を`await`していないため警告がでます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/4579ced1-1655-b99b-6d74-e73aa4971a3f.png)

これは`TweenPosition`の戻り値が`Task`であるためなので、
戻り値の型を`void`にしてやると警告は出なくなりますが、
そのまま変更すると今度は`TweenPosition`を`await`できなくなります。

そこで、何もしない拡張メソッド `Forget()` を追加することで警告を出さないようにしましょう。
以下は`Forget`の実装と、使用例です。

```csharp:MyTweenクラスに追加
// Task を await しなかったときでも警告が出ないようにするための拡張メソッド
public static void Forget(this Task task) { }
```
```csharp:使用例
// awaitしなくても警告が出なくなる
MyTween.TweenPosition(transform, startPos, endPos, duration).Forget();
// awaitもできる
await MyTween.TweenPosition(transform, startPos, endPos, duration);
```

## UnityActionを使用してなんでもTweenする

前章まででは、`position`の変更のみに対応したTweenを実装してきました。
この処理を基準に`position`以外にも使い回せるようにしていきましょう

### Mathf.Lerp

今までは

`Vector3.Lerp(a, b, t);`
と`Vector3`のLerpを使用してきましたが、

`Mathf.Lerp(a, b, t);`
という`float`型のLerpも用意されています。

`float`型の数値をTweenさせてその値を使用することで、様々な要素のTweenに応用できます。

例えば、

`Text`の色をTweenさせたい場合は、
`text.color = new Color(r, g, b);`
の `r`,`b`,`g` をTweenさせることで実現でき、

`Text`の数値をTweenさせたい場合は、
`text.text = x.ToString();`
の `x` をTweenさせることで実現できます。

### UnityAction

Tweenさせた値をどのように貰うかという問題については、`UnityAction` と `ラムダ式` を使用することで解決します。

次のコードでは、float型の変数`value`をTweenさせ、`value`の更新時に`UnityAction`を実行することで、更新した`value`を渡し、`position.x`をTweenさせています。

```csharp:MyTween内に追加した関数
public static async Task TweenFloat(float startValue, float endValue, float duration, UnityAction<float> action)
{
    float deltaTime = 0f;

    float value = startValue;

    while (deltaTime <= duration)
    {
        // 1フレームの間の秒数を足す
        deltaTime += Time.deltaTime;

        // valueを求める
        value = Mathf.Lerp(startValue, endValue, deltaTime/duration);
        
        // valueの更新を伝える
        action.Invoke(value);

        // 1フレーム待つ
        await Task.Yield();
    }
}
```

```csharp:変更したTweenTestの処理
void Start()
{
    // Tweenを開始する
    MyTween.TweenFloat( -3, 3, duration, (x) =>
    {
        transform.position = new Vector3(x, transform.position.y, transform.position.z);
    }).Forget();
}
```

先ほどの例に挙げていた`Text`のTweenは以下のようにして実装できます。

```csharp:TextのTween
[SerializeField] Text text;
    
void Start()
{
    // Tweenを開始する
    MyTween.TweenFloat( 0, 1, duration, (x) =>
    {
        text.text = x.ToString();
        text.color = new Color(x, x, x);
    }).Forget();
}
```

動かしてみるとこのようになります

![Linear Tween Text Value Color.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/fc6a0ba2-358c-d792-58b2-ee2351dec6ac.gif)


## イージング

次に`TweenFloat`にイージングを付けてみましょう。

### イージングとは？

イージングとは、Tweenに緩急をつけることで動きに面白さを加えられる機能です。

例えば、プレイヤーが移動を

+ 最初はゆっくり加速
+ 真ん中で最速になる
+ 最後は減速して止まる

のように実装するとリアルな動きになります。

### イージングの実装と関数

急ですがここで下図のような関数を考えてみます[^Geogebra]

[^Geogebra]:https://www.geogebra.org/graphing

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/37d50daf-f52e-9ce0-f027-feade7362c4f.png)

この関数に対して`deltaTime/duration`を入力として入れると、
**Tweenが始まってすぐ(deltaTime/duration ≒ 0)** では`y`の値が急激に増え、 
**Tweenが終わる頃(deltaTime/duration ≒ 1)** になると`y`の値がゆっくり増えます。


この関数を使った値をLerp関数に入れることではじめは速く、終わりはゆっくりなEasingを実現できます。

実際に何種類かのEasingを実装したものが次のコードです。
ちなみにEasing用の関数は**Geogebra[^Geogebra]** 等を使用すると作りやすいです。

```csharp:Easingを実装したMyTween
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

public static class MyTween
{
    // floatの値をTweenする関数
    public static async Task TweenFloat(float startValue, float endValue, float duration, EasingType easingType , UnityAction<float> action)
    {
        float deltaTime = 0f;

        float x,y;
        float value = startValue;

        while (deltaTime <= duration)
        {
            // 1フレームの間の秒数を足す
            deltaTime += Time.deltaTime;

            // Easing
            x = deltaTime / duration;
            y = ConvertEasingValue(x, easingType);

            // valueを求める
            value = Mathf.Lerp(startValue, endValue, y);
            
            // valueの更新を伝える
            action.Invoke(value);

            // 1フレーム待つ
            await Task.Yield();
        }
    }

    // Task を await しなかったときでも警告が出ないようにするための拡張メソッド
    public static void Forget(this Task task) { }

    // EasingTypeごとに値を返す関数
    private static float ConvertEasingValue(float x, EasingType easingType)
    {
        switch (easingType)
        {
            case EasingType.Linear:
                return x;
            case EasingType.QuadOut:
                return -(x - 1) * (x - 1) + 1;
            case EasingType.SinIn:
                return Mathf.Sin(Mathf.PI * 0.5f * (x - 1)) + 1;
            default:
                return x;
        }
    }

    // Easingの種類
    public enum EasingType
    {
        Linear,
        QuadOut,
        SinIn,
    }
}
```

使用するとこんな感じ

上：QuadOut , 中：SinIn , 下：Linear
![Tween Easing 3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/4a8de1af-1c22-44e2-69ea-d0e1111d6b93.gif)


## CancellationToken

オブジェクトが破壊された、途中でシーンが変わった、途中でゲームが終了した

といったときに`Task`を停止するために`CancellationToken`を持たせましょう。

`Task.Yield();`
には`CancellationToken`を渡すことができないので、キャンセルを判定して`return`します。

```csharp:CancellationTokenを追加したMyTween
public static async Task TweenFloat(float startValue, float endValue, float duration, EasingType easingType , UnityAction<float> action, CancellationToken token)
{
    float deltaTime = 0f;

    float x,y;
    float value = startValue;

    while (deltaTime <= duration)
    {
        // タスクを終了する
        if (token.IsCancellationRequested)
        {
            return;
        }

        // 1フレームの間の秒数を足す
        deltaTime += Time.deltaTime;

        // Easing
        x = deltaTime / duration;
        y = ConvertEasingValue(x, easingType);

        // valueを求める
        value = Mathf.Lerp(startValue, endValue, y);
        
        // valueの更新を伝える
        action.Invoke(value);

        // 1フレーム待つ
        await Task.Yield();
    }
}
```

使用側についてですが、
Unityには`destroyCancellationToken`という、`Monobehaviour`が破棄された時のための`CancellationToken`が用意されているので、これを`TweenFloat`を実行するときに渡します。

```csharp:Tween実行側にtokenを渡す処理を追加
// Tweenを開始する
MyTween.TweenFloat(-3, 3, duration, MyTween.EasingType.Linear, (x) =>
    {
        transform.position = new Vector3(x, transform.position.y, transform.position.z);
    }, 
    destroyCancellationToken).Forget();
```


## ちょっと変わったEasing関数

自作Tweenならではの変わったEasingを紹介します。

### Random
開始値から終了値までのランダムな値をとるTween

使用例
```csharp:ルーレット
void Start()
{
    // Tweenを開始する
    Roulette(destroyCancellationToken).Forget();
}

async Task Roulette( CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        await MyTween.TweenFloat(0, 100, 1f, MyTween.EasingType.Random, (x) =>
            {
                text.text = ((int)x).ToString();
            }, 
            token);

        await Task.Delay(500, token);
    }
}
```

動かすとこんな感じ

![Tween random.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/48a0faf0-9694-eaa8-9b98-952741acde60.gif)


### Beat
脈打つようなTween

グラフ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/bb4ac01b-1feb-4d48-8327-4bc4c6fdccc5.png)

使用例

```csharp:使用例：Beat
void Start()
{
    // Tweenを開始する
    Beat(destroyCancellationToken).Forget();
}

async Task Beat(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        await MyTween.TweenFloat(6, 8f, 0.2f, MyTween.EasingType.Beat, (x) =>
            {
                transform.localScale = new Vector3(x, x, transform.position.z);
            }, 
            token);

        await Task.Delay(800, token);
    }
}
```

動かすとこんな感じ

![Tween beat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3749182/71e7f00c-c4b3-186c-6250-9b7c3c36630c.gif)


## 完成品

今までの機能を盛り込んだ`MyTween`クラス

```csharp:MyTween.cs
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

public static class MyTween
{
    public static async Task TweenFloat(float startValue, float endValue, float duration, EasingType easingType , UnityAction<float> action, CancellationToken token)
    {
        float deltaTime = 0f;

        float x,y;
        float value = startValue;

        while (deltaTime <= duration)
        {
            // タスクを終了する
            if (token.IsCancellationRequested)
            {
                return;
            }

            // 1フレームの間の秒数を足す
            deltaTime += Time.deltaTime;

            // Easing
            x = deltaTime / duration;
            y = ConvertEasingValue(x, easingType);

            // valueを求める
            value = Mathf.Lerp(startValue, endValue, y);
            
            // valueの更新を伝える
            action.Invoke(value);

            // 1フレーム待つ
            await Task.Yield();
        }
    }

    // Task を await しなかったときでも警告が出ないようにするための拡張メソッド
    public static void Forget(this Task task) { }


    private static float ConvertEasingValue(float x, EasingType easingType)
    {
        switch (easingType)
        {
            case EasingType.Linear:
                return x;
            case EasingType.QuadOut:
                return -(x - 1) * (x - 1) + 1;
            case EasingType.SinIn:
                return Mathf.Sin(Mathf.PI * 0.5f * (x - 1)) + 1;
            case EasingType.Beat:
                return Mathf.Pow(Mathf.Sin(Mathf.PI * x), 3);
            case EasingType.Random:
                return Random.Range(0f, 1f);
            default:
                return x;
        }
    }


    public enum EasingType
    {
        Linear,
        QuadOut,
        SinIn,
        Beat,
        Random,
    }
}
```

# 最後に

本記事では`Lerp`や`Task`を使用して簡単なTweenを作成しました。
パフォーマンスの観点では`Task`を使用している点がダメそうですが、1時間足らずで実装できるため学習用に実装してみてはいかがでしょうか。