【さらばTime.timeScale】バトルにおける複雑なスピード制御を実現するための設計思想【Unity】

この記事は [サムザップAdventCalendar2022](https://qiita.com/advent-calendar/2022/sumzap) の12/23の記事になります！

# はじめに
株式会社サムザップでUnityエンジニアをしているレオです。（[Twitter](https://twitter.com/chocsar_tech)）
いきなりですが、皆さんはこんな経験ないでしょうか？

![スクリーンショット 2022-12-20 16.28.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/36a1c450-9756-a33d-f8b1-09d9ff60f402.png)
開発中のゲームに**一時停止機能**や**スローモーション機能**をつけたいと思ってググってみると、**Time.timeScale**についての記事がいっぱい出てくると思います。

<br>

ここで少し『スターオーシャン2』のバトルを **スピード制御** という点に注目して見てみましょう。（20秒ほどでOKです。）

<br>

<iframe width="100%" height="400" src="https://www.youtube.com/embed/CSb2SQ2xO_c?start=3007" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

動画で確認できるスピード制御をちょっと書き出してみます。

- **特定のスキル演出中は周りを停止している**
- **コマンド選択中はUI以外停止している**
- **UIは常に動いているものもあれば、止まるものもある**
- **スタン中はキャラのアニメーションが停止している**

それ以外にも、世の中にあるゲームでよくあるスピード制御として

- **倍速ボタンを押したら倍速になる**
- **メニューボタンを押したら一時停止する**
- **ヘイスト/スロウでキャラのアニメーションが速くなる/遅くなる**
- **ヒットストップ**
- **カットイン演出**

などもあり、それらが互いに影響し合うことも考えると、**バトルにおけるスピード制御はかなり複雑**なことがわかってもらえるんじゃないかなと思います。

![スクリーンショット 2022-12-20 16.28.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/8d54e698-78e5-f29f-9c24-0f651c136c17.png)

それに対して**Time.timeScale**だけでは無理がありますよね。この記事では、このような**複雑なスピード制御をどうすればうまく実現できるのか**について話していければと思います！



# 本質となるアイデアについて 
![スクリーンショット 2022-12-20 10.53.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/40f21bd8-c02a-1dd8-d6fb-68e4c73fe162.png)
本記事で伝えたい設計思想において最も重要となるアイデアは、**制御したいモノごとにスピードを持たせる**ということです。

![スクリーンショット 2022-12-20 10.53.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/df0f178a-7ef0-7197-bf6e-995af4a83ac5.png)
例えばですが、先ほど紹介した『スターオーシャン2』のバトルがこのようなクラス構造になっているとしましょう。これに対して、以下のような**Speedクラス**を考えてみます。

```C#:Speed.cs
public class Speed
{
    private float _speed = 1f;
    public float CurrentSpeed => _speed;

    public Action<float> OnValueChange;

    public void SetSpeed(float speed)
    {
        _speed = speed;
        OnValueChange?.Invoke(speed);
    }
}
```
Speedクラスはスピードの値を1つ持っており、その値が変化するとイベントを発火させるようなシンプルなものです。


![スクリーンショット 2022-12-20 10.54.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/a476bd54-638e-7d31-907c-05662a6cef93.png)
それをこのように、それぞれのクラスに持たせてあげます。例えばCharacterControllerであれば以下のような具合です。


```C#:CharacterController.cs
public class CharacterController : MonoBehaviour
{
    // Speedインスタンスを持たせる
    private readonly Speed _speed = new();

    // 初期化
    public void Initialize()
    {
        _speed.OnValueChanged = UpdateSpeed;
    }

    private void UpdateSpeed(float speed)
    {
        // ここでスピードの更新処理
    }
}
```
ここで、UpdateSpeedの中には具体的にどういう実装を書くのか考えてみましょう。


```C#:UpdateSpeedの中身の例
private Animator _animator;
private SkeletonAnimation _skeletonAnimation;
private ParticleSystem _particle;
private Tween _tween;
private Sequence _sequence;
private PlayableDirector _director;

private void UpdateSpeed(float speed)
{
    // Animatorのスピード更新
    _animator.speed = speed;
    
    // Spineのスピード更新
    _skeletonAnimation.timeScale = speed;
    
    // ParticleSystemのスピード更新
    var main = _particle.main;
    main.simulationSpeed = speed;
    
    // DOTweenのスピード更新
    _tween.timeScale = speed;
    _sequence.timeScale = speed;
    
    // Timelineのスピード更新
    for (var i = 0; i < _director.playableGraph.GetRootPlayableCount(); i++)
    {
        _director.playableGraph.GetRootPlayable(i).SetSpeed(speed);
    }
}
```
いろいろなパターンを書いてみましたが、ここで言いたいのは **アニメーションや演出を作るクラスには、スピードを設定するための呼び出し口がある** ということです。ゲームの見た目を作るために使っているものが何であれ、大体同じようにスピードを変化させることができると思います。

![スクリーンショット 2022-12-20 10.56.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/86c5aff9-6ac5-4b8b-2469-a40a93754041.png)
というわけで、このような処理の流れが理解できたら、ここから先は**Speedクラスに対してどのように値をセットすれば良いか**ということを考えていきましょう！


# バトル全体のスピードを制御する 
まずはシンプルな**倍速**と**一時停止**から考えていきたいと思います。


![スクリーンショット 2022-12-20 11.06.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/6f2796b7-c431-a189-c3d7-9616e2d1fa33.png)
このようにシングルトンで**BattleSpeedController**を作り、各Speedインスタンスに対してスピードの値を通知してあげれば良さそうです。

まずはBattleSpeedControllerが、各Speedインスタンスへの参照を持つようにするところから実装していきます。

```C#:BattleSpeedController.cs
public class BattleSpeedController : SingletonMonoBehaviour<BattleSpeedController>
{
    private readonly List<Speed> _speedList = new();

    // 購読開始
    public void Subscribe(Speed speed)
    {
        _speedList.Add(speed);
    }

    // 購読終了
    public void Unsubscribe(Speed speed)
    {
        _speedList.Remove(speed);
    }
}
```
```C#:CharactorController.cs
public void Initialize()
{
     _speed.OnValueChanged = UpdateSpeed;

    // スピードの購読を開始する
    BattleSpeedController.Instance.Subscribe(_speed);
}
```

このようにSubscribeする形でBattleSpeedControllerからスピードの値を受け取れるようにします。

次は、スピードの値を計算して通知する部分を実装をしていきましょう。



```C#:BattleSpeedController.cs
private float _battleSpeed = 1f;
private bool _isPause;

// 倍速を設定する
public void SetDoubleSpeed(bool isDoubleSpeed)
{
    _battleSpeed = isDoubleSpeed ? 2f : 1f;
    Notify();
}

// 一時停止を設定する
public void SetPause(bool isPause)
{
    _isPause = isPause;
    Notify();
}

// 各Speedインスタンスに通知
private void Notify()
{
    var speed = CalculateSpeed();
    foreach (var instance in _speedList)
    {
        instance.SetSpeed(speed);
    }
}

// スピードの計算
private float CalculateSpeed(Speed instance)
{
    if (_isPause) return 0f;
    return _battleSpeed;
}
```

あとは「倍速ボタンを押したらSetDoubleSpeedを呼び出す」などのようにしてあげれば、バトル全体のスピードを変化させることができます。


![スクリーンショット 2022-12-20 15.48.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/69c011f7-9325-deb9-f267-8b04bff8132f.png)
ここで重要なのは、

- **BattleSpeedControllerが複数がパラメータを持っている**
- **それらを使って最終的なスピードの値を計算している**

ということです。

ここからさらに応用していきましょう。最初に挙げたいくつかのスピード制御のうち「**特定のスキル演出中は周りを停止する**」を実現することを考えてみます。

![スクリーンショット 2022-12-20 11.07.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/73c218b4-7f58-4915-edf3-531a1745fb4c.png)
まずは最終的に通知されるスピード値の「こうなって欲しい」を図に表してみました。

- 今から実行するスキルが持つSpeedインスタンスには**そのままのスピード値**
- それ以外の全てのSpeedインスタンスには**0f**

を通知することができれば、今回やりたいことは実現できそうです。そのような**選択的なスピード値の通知**を実現するために、Speedインスタンスに**フィルター**を追加することを考えてみます。

![スクリーンショット 2022-12-20 11.07.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/91f65716-daa9-c8fc-eca6-b9b7a6996cfc.png)
BattleSpeedControllが持つパラメータをさらに増やしつつ、**「複数のパラメータのうちどれを計算に用いるか」をSpeedインスタンスごとに選択できるようにしてあげる**と、今回のようなスピード制御を実現できそうです。

それでは具体的な実装を考えていきましょう。

```C#:Speed.cs
// スキルによって制御されるスピードを使うかどうか
// これがフィルターの役割を果たす
public bool UseSkillControlledSpeed { get; set; } = true;
```

```C#:BattleSpeedController.cs

private float _battleSpeed = 1f;
private float _skillControlledSpeed = 1f; // ここにパラメータを追加
private bool _isPause;

// スキルによって制御されるスピードを設定する
public void SetSkillControlledSpeed(float speed)
{
    _skillControlledSpeed = speed;
    Notify();
}

// 各Speedインスタンスに通知
private void Notify()
{
    foreach (var instance in _speedList)
    {
        var speed = CalculateSpeed(instance);
        instance.SetSpeed(speed);
    }
}

// スピードの計算
private float CalculateSpeed(Speed instance)
{
    if (_isPause) return 0f;

    var speed = _battleSpeed;
    // インスタンスごとにフィルタリングする
    if (instance.UseSkillControlledSpeed) speed *= _skillControlledSpeed;

    return speed;
}
```

あとはスキル演出を制御するSkillControllerクラスで、以下のような実装をしてあげれば「**特定のスキル演出中は周りを停止する**」を実現できます。


```C#:SkillController.cs
public async UniTask Play()
{
    // 自身のSpeedインスタンスではフィルタリングするように設定
    _speed.UseSkillControlledSpeed = false;
    BattleSpeedController.Instance.SetSkillControlledSpeed(0f);

    // スキル演出の再生
    await PlaySkill();

    // 元に戻す
    _speed.UseSkillControlledSpeed = true;
    BattleSpeedController.Instance.SetSkillControlledSpeed(1f);
}
```
最初に挙げていた「**カットイン演出**」も、スピード制御としては同じなので同様の実装で実現できると思います。

![スクリーンショット 2022-12-20 11.08.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/677bd932-4ff7-1f7f-2b30-5e1da152714e.png)
このように、

- BattleSpeedControllerの**複数のパラメータ**
- Speedの**複数のフィルター**

を組み合わせることで、複雑なスピード制御も実現できるというのがポイントです！


![スクリーンショット 2022-12-20 11.08.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/582369a2-08a8-1115-50fb-ce101e92b690.png)
UIについても考えてみます。例えばですが上記のように、

- 倍速設定時にアニメーションが**倍速になる/ならない**
- スキル演出やコマンド選択中にアニメーションを**止める/止めない**

など、行いたいスピード制御がUIごとに細かく異なっているような複雑な仕様だったとしても、**パラメータとフィルターを増やしていくことで競合せずに実現できる**と思います。



# キャラごとのスピードを制御する 
最初に挙げたいくつかのスピード制御のうち、まだ触れてないものを改めて列挙してみましょう。

- **スタン中はキャラのアニメーションを停止させる**
- **ヘイスト/スロウでキャラのアニメーションが速くなる/遅くなる**
- **ヒットストップ**

これらはバトル全体ではなく**キャラクターに紐づくスピード制御**になるので、キャラクター側にうまく実装を加えることで実現していきたいと思います。

![スクリーンショット 2022-12-20 11.09.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/574a26f6-3a10-f4dd-1b5c-d31fddbad204.png)
これまでのスピード制御は、BattleSpeedControllerからスピードの値を通知してもらう構造になっていました。

![スクリーンショット 2022-12-20 11.10.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/ed11c970-c587-20dc-715f-433739593051.png)
これを上記のように、

- BattleSpeedContollerからのスピード値を受け取るSpeedクラス
- キャラ自身が持つパラメータ

をまとめた**CharacterSpeedクラス**を作れば実現できそうです。

**新たなスピード制御のためにパラメータを増やす**という考え方はこれまでと同じで、それを持たせる場所が今回はBattleSpeedControllerではなくキャラクターになった、というように考えると良いです。

では具体的な実装を考えてみましょう。

```C#:CharacterSpeed.cs
public class CharacterSpeed
{
    private readonly Speed _baseSpeed = new();
    private float _animationSpeed; // ここに追加！

    public float CurrentSpeed => _baseSpeed.CurrentSpeed * _animationSpeed;

    // ベースのスピードだけ反映したい場合もあるのでイベントは2つ用意している
    // 例えば、キャラに紐づくエフェクトのスピードなど
    public Action<float> OnChangeAnimationSpeed;
    public Action<float> OnChangeBaseSpeed;

    public void Initialize()
    {
        _baseSpeed.OnValueChange = baseSpeed =>
        {
            OnChangeBaseSpeed?.Invoke(baseSpeed);
            OnChangeAnimationSpeed?.Invoke(baseSpeed * _animationSpeed);
        };

        BattleSpeedController.Instance.Subscribe(_baseSpeed);
    }

    public void SetAnimationSpeed(float speed)
    {
        _animationSpeed = speed;
        OnChangeAnimationSpeed?.Invoke(_baseSpeed.CurrentSpeed * speed);
    }
}
```
あとは「スタンになったらSetAnimationSpeedを呼び出す」などのようにしてあげれば、キャラごとのスピード制御が実現できます。

ヒットストップを例にして考えてみると、以下のような実装になります。

```C#:CharacterController.cs
private readonly CharacterSpeed _characterSpeed = new();

public async UniTask PlayHitStop(float delaySeconds)
{
    _characterSpeed.SetAnimationSpeed(0f);
    await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds));
    _characterSpeed.SetAnimationSpeed(1f);
}
```
Speedクラスの代わりに、先ほど実装した**CharacterSpeedクラス**を持たせることで、ヒットストップを実現することができます。

ここで少し脱線しますが、『スマブラ』におけるヒットストップに注目してみましょう。まずはこちらの動画を見てみてください。（10秒ほどでOKです。）

<br>

<iframe width="100%" height="400" src="https://www.youtube.com/embed/Bf-S5QaTumA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

![スクリーンショット 2022-12-21 7.20.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/f36fd686-7434-d9c9-7b4c-9a822ffdf4cc.png)
よく見てみるとヒットストップの演出が2種類あることがわかります。今話している**キャラごとのスピード制御**として実現すべきなのは上の方です。（下はカットイン演出と考えるのが良さそうです。）



# その他のテクニック
### 待機処理について 
```C#:待機処理 その1
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds));
```
ヒットストップの話で上記のような実装をしていましたが、実はこれには問題があります。
**全体のバトルスピードが2倍になっているなら、待機する秒数は半分になって欲しい**ですが、この実装ではそれを実現できていません。

この問題を改善していきましょう。


```C#:BattleSpeedController.cs
// 自分でもSpeedインスタンスを持つ
private readonly Speed _speed = new();
public float BaseSpeed => _speed.CurrentSpeed;
```

```C#:待機処理 その2
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds / BattleSpeedController.Instance.BaseSpeed));
```
このようにすればバトルスピードに応じて待機時間を変化させることができていますが、これでもまだ不十分です。というのもこの実装では**Delay中のバトルスピード変化**に対応できていないからです。

```C#:待機処理 その3
var elapsedTime = 0f;
while (elapsedTime < delaySeconds)
{
    await UniTask.Yield();
    elapsedTime += Time.deltaTime * BattleSpeedController.Instance.BaseSpeed;
}
```
このように**毎フレームでバトルスピードに応じた経過時間を考える**ことで、**バトルスピードに依存した待機処理**を実現することができます。


### Updateについて 
「一定時間が経過したら何かしらの処理を行う」のようなことをやりたい場合は、以下のように実装すれば良いと思います。

```C#: MonobehaviourのUpdate
private void Update()
{
    // この値をdeltaTimeだと見なしてUpdate処理をしていく
    var deltaTime = Time.deltaTime * BattleSpeedController.Instance.BaseSpeed
    _timeCounter.Update(deltaTime);
}
```

```C# TimeCounter.cs
public class TimeCounter
{
    private float _intervalSeconds = 5f;
    private float _currentTime;

    public void Update(float deltaTime)
    {
        _currentTime += deltaTime;      
        if (_currentTime > _intervalSeconds)
        {
            // 何かしらの処理    
        }
    }
}
```


# 最後に 
![スクリーンショット 2022-12-20 11.15.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/965947/c1fa888b-fe16-e0e5-6eae-c7d73f45fb88.png)
以上になります。Time.timeScaleは手軽ですが、**より複雑なスピード制御を実現したい**となると扱いづらいので、本記事で紹介した考え方をぜひ参考にしてもらえればと思います！