ゴマちゃんフロンティア

アザラシが大好きなエンジニアの開発日記です

【Unity】ポーズ機能をTime.timeScaleを変えずに実装したお話

time 2020/01/23

今回はUnityの「ポーズ機能」の実装に関するお話です。
Unityでポーズというと、「Time.timeScaleを0にする」といった内容のページが多くヒットします。単純なポーズ機能だけならこれで実現できますが、

  • ポーズ時にメニューをアニメーションさせながら表示したい
  • メニュー画面でキャラクターをアニメーションさせたい

などの場合に「timeScale=0」で実装していると、それらのアニメーションまで止まってしまいます。
それを抜きにしても、Tween系ライブラリのアニメーションやパーティクルまで止まると厳しいので、他のやり方を考えてみた次第です。

ということで、やってみた感じでは「ポーズ時とポーズ解除時にイベントを用意し、各コンポーネントがイベントを登録して通知してもらう」でいけそうです。
デザインパターンでいう「Observerパターン」ですね。

UniRxの導入

今回の実装はC#のeventの仕組みを使いますが、Unityでeventを扱う場合は「UniRx」が便利だそうな。
AssetStoreから無料でダウンロードできます。
https://assetstore.unity.com/packages/tools/integration/unirx-reactive-extensions-for-unity-17276

実装

ポーズを管理するクラスの作成

まずは今回の主役、ポーズに関するあれこれを管理するクラスを作成します。

 
using DG.Tweening;
using System;
using UniRx;
using UnityEngine;

public class GameTimeManager : MonoBehaviour
{
    private static Subject<string> pauseSubject = new Subject<string>();
    private static Subject<string> resumeSubject = new Subject<string>();

    private static bool isPaused = false;

    public static IObservable<string> OnPaused
    {
        get { return pauseSubject; }
    }

    public static IObservable<string> OnResumed
    {
        get { return resumeSubject; }
    }

    public static void Pause()
    {
        isPaused = true;
        
        pauseSubject.OnNext("pause");
        DOTween.Pause(GameConstants.DOTWEEN_PAUSE_TWEEN_ID);
    }

    public static void Resume()
    {
        isPaused = false;

        resumeSubject.OnNext("resume");
        DOTween.Play(GameConstants.DOTWEEN_PAUSE_TWEEN_ID);
    }

    /// <summary>
    /// ポーズ中かどうか
    /// </summary>
    /// <returns></returns>
    public static bool IsPaused()
    {
        return isPaused;
    }
}

privateフィールドでSubjectのインスタンスを作成し、外部からはプロパティで参照だけ許可するようにします。
合わせてPause()Resume()メソッドを作成し、これを呼び出すことでポーズ開始・解除を切り替えます。
中でOnNext()メソッドを実行し、登録されているイベントに通知します。引数は使わないので適当です。

ポーズする必要のあるコンポーネントからイベントを登録

「ポーズする必要のあるコンポーネント」ですが、一般的には「キャラクターのアニメーション」「物理演算」「操作の受付」などを担っているコンポーネントが概要するかと思います。
それらのコンポーネントで GameTimeManagerOnPausedOnResumed にイベントを登録します。イベントの登録は Subscribe() メソッドで行います。

以下の例ではStart()内でそれぞれのイベントを登録し、AnimatorとRigidbodyの動作を変更させています。

 
protected Animator animator;
protected Rigidbody rb;

private void Start()
{
    animator = this.GetComponent<Animator>();
    rb = this.GetComponent<Rigidbody>();

    // ポーズ時の動作を登録
    GameTimeManager.OnPaused.Subscribe(x => {
        animator.speed = 0;
        rb.Pause(gameObject);
    }).AddTo(this.gameObject);
    GameTimeManager.OnResumed.Subscribe(x => {
        animator.speed = 1f;
        rb.Resume(gameObject);
    }).AddTo(this.gameObject);
}

RigidBodyの一時停止・再開は以下のサイトを参考にさせていただきました!
拡張メソッドを作ってそれを呼び出す感じですね。
http://kan-kikuchi.hatenablog.com/entry/Pause_Resume

AnimatorはSpeedを0にすればOKです。ここでいうスピードはAnimatorControllerの各ステートのSpeedとは別物なので、「元のSpeedの値を保持しておく」といった配慮は不要です。

注意点その1は「イベントを登録する側のusingにもUniRxを含める」ことです。
UniRxのusingが抜けていると、イベント登録時のラムダ式が渡せずエラーになってしまいます。このusingはVisualStudioでは補完してくれないので、自前で追加しましょう。

注意点その2は「Subscribe()時にAddTo()を呼び出す」ことです。
これが抜けていると、GameObjectやコンポーネントが破棄された場合でもイベントが登録されっぱなしになってしまいます。
AddTo()メソッドで自身のGameObjctを指定し、破棄時にイベント登録が解除されるようにします。

動作イメージ

先日の「unity1week Meetup」でも展示した、「くりとりうさぴょん」での例です。
うさぴょんの操作には「CharacterController」を使用し、自前のスクリプトで落下速度を計算して移動させているので、ポーズ時はその処理をスキップさせています。
またDynamicBoneはenabledを切り替えていますが、DynamicBoneクラスのEnabled()で位置がリセットされてしまうので、その処理を無効化しました。

ポーズ時にうさぴょんは空中で止まっていますが、ポーズメニューを表示するDOTweenアニメーションは動いていますね。
ただ背景の草が動いてしまっています。これはTerrainで行っていてどうしようもないため妥協しました。

ちなみにDOTweenはTweenインスタンスに`SetUpdate(true)`を指定すると、timeScaleを0にしても動くようになります。
「手っ取り早く`timeScale=0`でやりたい!でもDOTweenだけは使いたい!」な方は参考にしてみてください。

あとがき

そんなわけで、timeScaleを弄らずにポーズ機能を実現する方法を考えてみました!

実際のところ、「ポーズ時に止める必要のあるコンポーネント」ってなかなかたくさんあるので、思った以上に大変です。
特に外部のアセットを使っている場合、そのアセットで作られたコンポーネントの一時停止方法も確立しなければなりません。先ほどのくりとりうさぴょんの例でもDynamicBoneを少し弄る必要がありました。

なのでシンプルなポーズ機能だけであればtimeScale弄っちゃったほうが手っ取り早いかと。
しかしこの方法は知っておくとあれこれ可能性が広がりそうなので、覚えておきます。

down

コメントする