実務で使っているUnityの便利モジュール・実装パターン3選

DeNA Engineer Advent Calendar 2016 1日目の記事です。

こんにちは、ゲームエンジニアの宇塚です。
この記事では、自分がUnityでゲーム作りをする中で実装し実務で使っている便利モジュール・実装パターンとして以下3つを紹介します。

  • ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager
  • ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper
  • 複数の通信処理をまとめる処理のパターン

Unityと一部UniRxを前提としていますが、他言語や環境でも活用できると思います!

ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager

通信や大事な演出を待つために、ユーザーの入力を受け付けなくしたいことがあります。
ユーザーの入力を防ぐ処理に不具合があり入力を防いだ状態が解除されなくなったとしたら深刻な問題ですので、確実に解除される呼び出し方のみを提供するモジュール InputGuardManager を作りました。

InputGuardManager 本体

ユーザーの入力を防ぐ=ガードするメソッドが IDisposable を返し、それを Dispose することでガードが解除されるようになっています。



// ユーザーの入力を防ぐクラス。説明のために入力の種類を絞り簡略化しています。シングルトンです。
public class InputGuardManager : Singleton
{
    // モーダル(ダイアログなど)を表示するcanvas用のgraphicraycaster
    private GraphicRaycaster modalGraphicRaycaster;

    // 通常の2DUIを表示するCanvas用のGraphicRaycaster
    private GraphicRaycaster overlayGraphicRaycaster;

    // Androidのバックキーイベントを扱うクラス
    private BackKeyHandler backKeyHandler;

    // Modalのガードキーに対する参照カウントの辞書。
    private Dictionary modalGuardDict = new Dictionary();

    // Overlayのガードキーに対する参照カウントの辞書。
    private Dictionary overlayGuardDict = new Dictionary();

    // BackKeyのガードキーに対する参照カウントの辞書。
    private Dictionary backKeyGuardDict = new Dictionary();

    #region Public methods

    public void Initialize()
    {
        modalGraphicRaycaster = __取得処理__;
        overlayGraphicRaycaster = __取得処理__;
        backKeyHandler = __取得処理__;
    }

    // Modalのガードの有無。
    public bool IsModalGuarded {
        get { return modalGuardDict.Count() > 0; }
    }

    // Overlayのガードの有無。
    public bool IsOverlayGuarded {
        get { return overlayGuardDict.Count() > 0; }
    }

    // BackKeyのガードの有無。
    public bool IsBackKeyGuarded {
        get { return backKeyGuardDict.Count() > 0; }
    }

    // 指定したキーで、Modal・Overlay・BackKeyをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable Guard(string key)
    {
        GuardInternal(key);
        return Disposable.Create(() => UnguardInternal(key));
    }

    // 指定したキーで、Modalをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardModal(string key)
    {
        GuardModalInternal(key);
        return Disposable.Create(() => UnguardModalInternal(key));
    }

    // 指定したキーで、Overlayをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardOverlay(string key)
    {
        GuardOverlayInternal(key);
        return Disposable.Create(() => UnguardOverlayInternal(key));
    }

    // 指定したキーで、BackKeyをガードする。
    // 返り値のIDisposableをDisposeしたときにガード解除する。
    public IDisposable GuardBackKey(string key)
    {
        GuardBackKeyInternal(key);
        return Disposable.Create(() => UnguardBackKeyInternal(key));
    }

    #endregion

    private void GuardInternal(string key)
    {
        GuardModalInternal(key);
        GuardOverlayInternal(key);
        GuardBackKeyInternal(key);
    }

    private void UnguardInternal(string key)
    {
        UnguardModalInternal(key);
        UnguardOverlayInternal(key);
        UnguardBackKeyInternal(key);
    }

    private void GuardModalInternal(string key)
    {
        if (!modalGuardDict.ContainsKey(key)) {
            modalGuardDict.Add(key, 0);
        }
        modalGuardDict[key] = modalGuardDict[key] + 1;

        if (modalGuardDict.Count == 1 && modalGuardDict[key] == 1)
        {
            ChangeModalGuard(true);
        }
    }

    private void UnguardModalInternal(string key)
    {
        if (!modalGuardDict.ContainsKey(key) || modalGuardDict[key] <= 0) {
            return;
        }

        modalGuardDict[key] = modalGuardDict[key] - 1;

        if (modalGuardDict[key] <= 0) {
            modalGuardDict.Remove(key);
        }

        if (modalGuardDict.Count == 0) {
            ChangeModalGuard(false);
        }
    }

    private void GuardOverlayInternal(string key)
    {
        if (!overlayGuardDict.ContainsKey(key)) {
            overlayGuardDict.Add(key, 0);
        }
        overlayGuardDict[key] = overlayGuardDict[key] + 1;

        if (overlayGuardDict.Count == 1 && overlayGuardDict[key] == 1)
        {
            ChangeOverlayGuard(true);
        }
    }

    private void UnguardOverlayInternal(string key)
    {
        if (!overlayGuardDict.ContainsKey(key) || overlayGuardDict[key] <= 0) {
            return;
        }

        overlayGuardDict[key] = overlayGuardDict[key] - 1;

        if (overlayGuardDict[key] <= 0) {
            overlayGuardDict.Remove(key);
        }

        if (overlayGuardDict.Count == 0) {
            ChangeOverlayGuard(false);
        }
    }

    private void GuardBackKeyInternal(string key)
    {
        if (!backKeyGuardDict.ContainsKey(key)) {
            backKeyGuardDict.Add(key, 0);
        }
        backKeyGuardDict[key] = backKeyGuardDict[key] + 1;

        if (backKeyGuardDict.Count == 1 && backKeyGuardDict[key] == 1)
        {
            ChangeBackKeyGuard(true);
        }
    }

    private void UnguardBackKeyInternal(string key)
    {
        if (!backKeyGuardDict.ContainsKey(key) || backKeyGuardDict[key] <= 0) {
            return;
        }

        backKeyGuardDict[key] = backKeyGuardDict[key] - 1;

        if (backKeyGuardDict[key] <= 0) {
            backKeyGuardDict.Remove(key);
        }

        if (backKeyGuardDict.Count == 0) {
            ChangeBackKeyGuard(false);
        }
    }

    private void ChangeModalGuard(bool isGuarded)
    {
        modalGraphicRaycaster.enabled = !isGuarded;
    }

    private void ChangeOverlayGuard(bool isGuarded)
    {
        overlayGraphicRaycaster.enabled = !isGuarded;
    }

    private void ChangeBackKeyGuard(bool isGuarded)
    {
        backKeyHandler.enabled = !isGuarded;
    }
}

ユーザーの入力の種類が増えることに備え、Interfaceを作り入力毎のクラスを分けていくとより拡張しやすくなりますね。
使い方はこちら



IDisposable disposable = InputGuardManager.Instance.Guard("Login");
// ログインする通信処理をここで実行。この間ユーザー入力はガードされる。
disposable.Dispose();

InputGuardManager の入力ガード解除を忘れなくする拡張

上の使い方で Dispose をし忘れると、入力が防がれたままになってしまいます。
忘れないように以下のような使い方ができるようにしました。



Observable.ReturnUnit()
    .UsingInputGuardWith(
        "Login",
        _ => LoginProcess()
    )
    .Subscribe()
    .AddTo(subscriptions);

LoginProcess の処理を行っている間、指定したキー "Login" でユーザーの入力を防ぎます。
明示的に Dispose しなくてもガードが外れるので安心です。
実装はこちら



public static class Extension
{
    public static IObservable UsingInputGuardWith(
        this IObservable source,
        string guardKey,
        Func> observableFactory)
    {
        return source.Using(
            observableFactory, _ => InputGuardManager.Instance.Guard(guardKey)
        );
    }
}

ここで使われている Using は ReactiveX で提供されている機能を UniRx で使えるようにしたもので、明日の記事担当の @trapezoid さん作成のモジュールです。



public static class ObservableEx
{
    public static UniRx.IObservable Using(
        Func> observableFactory, Func resourceFactory)
    {
        return UniRx.Observable.Create (observer => {
            SerialDisposable disposable = new SerialDisposable();
            disposable.Disposable = resourceFactory();
            return StableCompositeDisposable.Create(
                observableFactory()
                    .Finally(() => disposable.Dispose())
                    .Subscribe(observer),
                disposable
            );
        });
    }

    public static UniRx.IObservable Using(
        this UniRx.IObservable source,
        Func> observableFactory,
        Func resourceFactory)
    {
        return source.SelectMany(
            value => Using(
                () => observableFactory(value), 
                () => resourceFactory(value)
            )
        );
    }
}

入力ガードしているのと同じ期間、画面上に読み込み中表示を出すという拡張も可能です。



// 接続中オーバーレイとInputGuardをUsingする。
// ここでは ConnectingOverlayManager の Show により読み込み中表示が出て、
// 返り値の IDisposable を Dispose することで表示解除されるとします。
public static IObservable UsingConnectingOverlayAndInputGuardWith(
    this IObservable source,
    string inputGuardKey,
    Func> observableFactory)
{
    return source.Using(observableFactory, _ => {
        return new CompositeDisposable(
            InputGuardManager.Instance.Guard(inputGuardKey),
            ConnectingOverlayManager.Instance.Show(
                ConnectingOverlayManager.OverlayType.Connecting
            )
        );
    });
}

InputGuardManager の入力ガード状況をリアルタイム反映する EditorWindow

InputGuardManager がユーザーの入力を防ぐ状況を UnityEditor でリアルタイム反映する EditorWindow です。
開発中に、意図した期間、意図したキーで、意図した入力が防がれていることを確認しやすくなります。



// InputGuardManager の入力ガード状況をリアルタイム表示する EditorWindow
// 情報の表示・変更のために InputGuardManager にデバッグ専用メソッドを幾つか足す必要があります。
public class InputGuardManagerEditor : EditorWindow
{
    private const float Height = 16f;
    private const float MinKeyColumnWidth = 120f;
    private const float FlagColumnWidth = 46f;

    [MenuItem("Window/InputGuardManagerEditor")]
    static public void OpenInputGuardManagerEditor()
    {
        EditorWindow.GetWindow(false, "InputGuard", true).Show();
    }

    // 秒間10回呼ばれるインスペクタのUpdateメソッド
    private void OnInspectorUpdate()
    {
        Repaint();
    }

    private void OnGUI()
    {
        if (!InputGuardManager.HasInstance()) {
            GUILayout.Label("InputGuardManagerが入力を防ぐ状況を表示・変更します。");
            return;
        }

        if (!Application.isPlaying) {
            GUILayout.Label("ゲームが起動していません。");
            return;
        }

        DrawGuardCondition();
    }

    private void DrawGuardCondition()
    {
        const float ColumnWidth = 60f;

        EditorGUILayout.BeginVertical();
        {
            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("Modal:   ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField("Overlay: ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField("BackKey: ", GUILayout.Width(ColumnWidth));
            EditorGUILayout.EndHorizontal();

            // 現在各入力が防がれているかどうかを表示
            EditorGUILayout.BeginHorizontal("AS TextArea", GUILayout.MinHeight(20f));
            EditorGUILayout.LabelField(InputGuardManager.Instance.IsModalGuardedForDebug ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField(InputGuardManager.Instance.IsOverlayGuardedForDebug ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.LabelField(!BackKeyManager.Instance.Enabled ?
                "Guarded" : "__", GUILayout.Width(ColumnWidth));
            EditorGUILayout.EndHorizontal();

            // 各入力をボタンで強制的に解除する
            // デバッグ中に入力ガードを外して動作確認するのに便利です
            EditorGUILayout.BeginHorizontal();
            {
                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllModalGaurdUnsafe();
                }
                EditorGUILayout.EndVertical();

                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllOverlayGuardUnsafe();
                }
                EditorGUILayout.EndVertical();

                EditorGUILayout.BeginVertical(GUILayout.Width(ColumnWidth));
                if (GUILayout.Button("Clear", GUILayout.Width(ColumnWidth))) {
                    InputGuardManager.Instance.ClearAllBackKeyGuardUnsafe();
                }
                EditorGUILayout.EndVertical();
            }
            EditorGUILayout.EndHorizontal();

            DrawKeys();
        }
        EditorGUILayout.EndVertical();
    }

    private void DrawKeys()
    {
        HashSet allKeySet
            = new HashSet(InputGuardManager.Instance.ModalGuardDictKeysForDebug);
        allKeySet.UnionWith(InputGuardManager.Instance.OverlayGuardDictKeysForDebug);
        allKeySet.UnionWith(InputGuardManager.Instance.BackKeyGuardDictKeysForDebug);

        if (allKeySet.Count == 0) {
            EditorGUILayout.LabelField("No keys found.");
            return;
        }

        DrawHeader();

        List keys = allKeySet.ToList();
        keys.Sort();

        foreach (string key in keys) {
            DrawKeyRow(key);
        }
    }

    private void DrawHeader()
    {
        EditorGUILayout.BeginHorizontal();
        {
            EditorGUILayout.LabelField("Registered Key", GUILayout.MinWidth(MinKeyColumnWidth));
            EditorGUILayout.LabelField("Modal", GUILayout.Width(FlagColumnWidth));
            EditorGUILayout.LabelField("Overlay", GUILayout.Width(FlagColumnWidth));
            EditorGUILayout.LabelField("BackKey", GUILayout.Width(FlagColumnWidth));
        }
        EditorGUILayout.EndHorizontal();
    }

    private void DrawKeyRow(string key)
    {
        EditorGUILayout.BeginHorizontal("AS TextArea", GUILayout.MaxHeight(Height));
        {
            EditorGUILayout.SelectableLabel(
                string.IsNullOrEmpty(key) ?
                "!!empty!!" :
                key, GUILayout.MinWidth(MinKeyColumnWidth), GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(
                InputGuardManager.Instance.GetModalGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(
                InputGuardManager.Instance.GetOverlayGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));

            EditorGUILayout.LabelField(InputGuardManager.Instance.GetBackKeyGuardCountForDebug(key).ToString(),
                GUILayout.Width(FlagColumnWidth),
                GUILayout.MaxHeight(Height));
        }
        EditorGUILayout.EndHorizontal();
    }
}

この EditorWindow を使うとリアルタイムでガード状況がわかり、不具合でガードされたままになるときもボタンでひとまずガード解除してデバッグを続けられます。

InputGuardManagerEditor.png

実機で入力ガードされたままになったときの状況を確認するDebugMenu

実機動作確認やQAで入力ガードされたままになった場合にも、ガードしているキーを確認して情報を得たり、ひとまずガードを外したりできるように DebugMenu があると便利です。
QAから開発にDebugMenuのスクリーンショットを送ってもらえば、原因特定が容易になります。
DebugMenu の実装については割愛しますが、上述の EditorWindow とほぼ同様のコードとなります。

InputGuardDebugMenu.png

ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper

stringのメッセージを表示し、ユーザーの「はい」か「いいえ」さらにAndroidのバックキーの入力によって後続の処理を変える汎用確認ダイアログを開くモジュールです。

呼び出し方



CompositeDisposable subscriptions = new CompositeDisposable();

private void OpenBattleConfirmationDialog()
{
    subscriptions.Clear();

    DialogHelper.ShowConfirmDialog("戦いを挑みますか?")
        .Where(decisionType => decisionType == DecisionType.Yes)
        .Subscribe(GoToBattle)
        .AddTo(subscriptions);
}

実装はこちら



public static class DialogHelper
{
    // ユーザーの決定した行動タイプ
    public enum DecisionType
    {
        Yes,      // はい
        No,       // いいえ
        BackKey,  // Androidのバックキー
    }

    public static IObservable ShowConfirmDialog(string message)
    {
        // 汎用ダイアログのクラス
        Dialog dialog = GetDialog();

        // Androidのバックキーイベントを扱うクラス
        BackKeyHandler backKeyHandler = GetBackKeyHandler();

        // Observable.Zip は、複数のストリームに1つずつメッセージが来た時点でそれらを合成して流します
        // Observable.Zmb は、複数のストリームのうち、最も早く流れてきたものを流します

        return Observable.Zip(
            Observable.Amb(
                // Yesが押されたとき
                dialog.OnSelectYes.Select(_ => DecisionType.Yes),
                // Noが押されたとき
                dialog.OnSelectNo.Select(_ => DecisionType.No),
                // BackKeyが押されたとき
                backKeyHandler.OnBack.Select(_ => DecisionType.BackKey)
            ).Do(_ => dialog.Hide()),         // Yes、No、BackKeyのいずれかが押されたらダイアログを閉じ始める
            dialog.OnFinishHideAnimation,     // ダイアログが閉じるアニメーションが終わるまで待つ
            (decisionType, _) => decisionType // Yes、No、BackKeyのどれが押されたかを後ろに流す
        ).First();                            // 最初の選択だけが流れるようにする
    }
}

dialog.Hide() の後に SelectMany で dialog.OnFinishHideAnimation を続けていないのは、もしアニメーションが全く設定されておらず Hide() の呼び出しの中で即座に OnFinishHideAnimation に流れてきてしまっていた場合、ShowConfirmDialogが何も流さなくなってしまうからです。

複数の通信処理をまとめる処理

複数の通信APIを組み合わせた複雑な処理を隠蔽するクラスを作るパターンを例示します。
ゲーム仕様によるのでこのまま使い回しはできませんが、複雑な処理をまとめたいときによく使っている手法です。

ギルドからの脱退処理

仕様

  • ギルドメンバーはギルド脱退通信を呼び出せる。
  • ギルドメンバーでなければ、ギルド非所属だとメッセージを出す。
  • ギルドメンバーならば、脱退させて成功メッセージを出す。
  • ギルドマスターなら、マスターは脱退できないとメッセージを出す。
  • ギルドにギルドマスター1人しかいないときは、代わりにギルド解散通信を呼び脱退成功メッセージを出す。

上記の仕様を実現するための一連の処理を隠蔽するクラスを作ることを考えます。 サーバーサイドでロジックを組むという方法も考えられますが、ここでは既に用意された信頼性の高い「ギルド脱退」「ギルド除法取得」「ギルド解散」といった個別のAPIを組み合わせて実現することとします。

一部処理を省略したコードが以下です。



public class LeaveGuildService
{
    public IObservable StartProcess()
    {
        return LeaveGuild()
            .CatchIf(
                (GuildException e) => e.GuildErrorId == GuildErrorId.NOW_GUILD_MASTER,
                e => OnNowGuildMasterError()
            );
    }

    // ギルド脱退処理
    private IObservable LeaveGuild()
    {
        return new LeaveGuildProcess().StartProcess()  // ギルド脱退APIを叩く
            .RethrowIf((NetworkException e) =>  {
                switch (e.ErrorId) {
                case NetworkErrorId.NOT_GUILD_MEMBER:  // ギルドメンバーではない場合
                case NetworkErrorId.NOW_GUILD_MASTER:  // ギルドマスターではない場合
                    return true;
                }
                return false;
            }, e => new GuildException(e))
            .__通信のリトライ処理__
            .AsUnitObservable();
    }

    // ギルド脱退処理で、ギルドマスターであるエラーが帰ってきたときに呼ぶ処理
    private IObservable OnNowGuildMasterError()
    {
        return GetMyGuildInfoModel()
            .SelectMany(guildInfoModel => CheckGuildMemberCount(guildInfoModel))
            .SelectMany(_ => DeleteGuild())
            .AsUnitObservable();
    }

    // 自分の所属ギルドの情報を取得する処理
    private IObservable GetMyGuildInfoModel()
    {
        return new GetMyGuildRecordProcess().StartProcess()  // 所属ギルド情報取得APIを叩く
            .RethrowIf(
                (NetworkException e) => e.ErrorId == NetworkErrorId.NOT_GUILD_MEMBER,
                e => new GuildException(e)
            )
            .__通信のリトライ処理__;
    }

    // ギルドの所属メンバー数が1人だけであるかを調べ、1人でないならGuildExceptionを流す。
    private IObservable CheckGuildMemberCount(GuildInfoModel guildInfoModel)
    {
        if (guildInfoModel.MemberCount != 1) {
            return Observable.Throw(
                new GuildException("ギルドの所属メンバーが1人だけではない",
                GuildErrorId.GuildMemberCountNotOne)
            );
        }

        return Observable.ReturnUnit();
    }

    // ギルド解散処理
    private IObservable DeleteGuild()
    {
        return new DeleteGuildProcess().StartProcess()  // ギルド解散APIを叩く
            .RethrowIf((NetworkException e) => {
                switch (e.ErrorId) {
                case NetworkErrorId.NOT_GUILD_MEMBER:   // ギルドメンバーではない場合
                case NetworkErrorId.NOT_GUILD_MASTER:   // ギルドマスターではない場合
                    return true;
                }
                return false;
            }, e => new GuildException(e))
            .__通信のリトライ処理__
            .AsUnitObservable();
    }
}

例外によって処理を様々に分岐させる必要がある処理を簡潔にまとめることができます。
このクラスを利用する側では個別の通信が上手くいったかどうかを気にすることなく成功か失敗かを受け取ることができます。
後でギルド脱退を呼び出したい場所が増えても安心です。

こういったServiceクラスを共通の形式で作ることで、より複雑な一連の処理もServiceクラスを結合した新たなServiceクラスとし、DRYな状態を保てます。

ここで使われている RethrowIf と CatchIf も @trapezoid さん作成のモジュールです。



public static class ErrorHandlingExtensions
{
    // 条件にマッチするExceptionがOnErrorに流れてきた時、別の例外に変換して後続に流す
    public static UniRx.IObservable RethrowIf (
        this UniRx.IObservable source, 
        System.Predicate predicate,
        System.Func conversion
    )
        where TSourceException : System.Exception
        where TDestinationException : System.Exception
    {
        return source.Catch ((TSourceException e) => {
            if (predicate (e)) {
                return UniRx.Observable.Throw (conversion (e));
            } else {
                return UniRx.Observable.Throw (e);
            }
        });
    }

    // 条件にマッチするExceptionがOnErrorに流れてきた時、別のストリームに差し替える
    // Catchの条件付与版
    public static UniRx.IObservable CatchIf (
        this UniRx.IObservable source, 
        System.Predicate predicate,
        System.Func> errorHandler
    )
     where TException : System.Exception
    {
        var result = source.Catch (
                e => predicate (e) ? errorHandler (e) : UniRx.Observable.Throw (e)
        );
        return result;
    }
}

終わりに

今回は、自分がゲーム作りをする中で実装し実務で使っている便利モジュール・実装パターンとして

  • ユーザー入力を安全に防ぎ、デバッグしやすくする InputGuardManager
  • ユーザー選択を簡単に取得する汎用確認ダイアログ呼び出し DialogHelper
  • 複数の通信処理をまとめる処理のパターン

を紹介しました。

ぜひ活用してみてください!

ツイート
シェア
あとで読む
ブックマーク
送る
メールで送る