【Unity】デバッグシステムを強化するSR Debuggerのタブ拡張手法

# 最初に

本記事は、SRDebuggerを用いて、Unity上でデバッグシステムを構築するためのテクニックをまとめたものとなります。SRDebuggerを使用しているプロジェクトのエンジニアさんへの一助となれば幸いです。

本記事内での環境は以下の通りです。

| 使用技術 | バージョン |
| --- | --- |
| Unity | 2023.2.15f1 |
| SRDebugger | 1.12.1 |

# SRDebuggerとは何か？

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/5906b4ee-2954-a169-0c56-757ee689dabc.png)

- Importするだけで、ランタイム上で動作するコンソール画面から、ゲームに関する様々なログが確認可能。UnityのDebug APIで出力されたログも表示されるので、実機上でも便利。
- セットアップも、ガイドに従うだけで、簡単にタブの無効化や、キーボードショートカットキーの変更などが行える。
- デフォルトで用意されているコンソールのタブからシステム情報や、プロファイラーグラフでのパフォーマンス計測をすることもできる。
- パネルをカスタマイズすることで、独自のデバッグコマンドを簡単に用意することも可能。
<br>
詳しい動作例は公式を見て頂くほうが早いでしょう。

（英語が読めなくても埋め込みのgifを見るだけで雰囲気がつかめます）

[SRDebugger | Stompy Robot](https://www.stompyrobot.uk/tools/srdebugger/)

<br>
以下の画像は、私個人のプロジェクト用に改造したものです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/74041cfe-e0e3-1284-8071-f20338070992.png)

以下に挙げる基本的な使い方を確認する際は、こちらの記事を参考にするのがおすすめです。

[【Unity】SRDebuggerの使い方と拡張方法の紹介 - Qiita](https://qiita.com/tomori_hikage/items/a2372333f8b73b421113)

- 導入方法
- 設定の変更
- 各タブの表示確認
- オプションタブへの簡易的なカスタムデバッグ機能追加
<br>

# タブの拡張方法

それでは今回の本題である、タブの拡張手法を見ていきましょう。

## タブ用のPrefab作成

`Assets/StompyRobot/SRDebugger/Resources/SRDebugger/UI/Prefabs/Tabs/Options.prefab`を複製してタブを追加します。

内部実装について、SRDebuggerのコード内では`Assets/StompyRobot/SRDebugger/Resources/SRDebugger/UI/Prefabs/Tabs`内にあるPrefabをタブとして扱い、生成する形になっています。
    
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/6290fa48-5491-1fcc-b565-f2762dbca904.png)

今回は私が現在開発しているプロジェクトをそのまま流用して作っていきます。

そのため、以下の三つのタブを用意することにしましょう。

- 汎用デバッグシステム用
- サウンドデバッグ用
- Twitchとの通信デバッグ用

`Options`というPrefabをOriginalPrefabとして扱い、そこから3つ複製してください。そこからそれぞれのPrefabに以下の名前をつけます。

- DebugOptions
- SoundOptions
- TwitchOptions

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/3d1872b0-f6df-bbbd-1b12-e683fb473b7b.png)

## 各タブへの設定

次に、それぞれのPrefab用に設定を変更していきます。

Prefabを選択してInspectorを確認すると、`SR Tab`というスクリプトがアタッチされているのがわかると思います。そちらのパラメータを変更することで、タブ名やアイコンを変えることができます。

それでは、さっそく変更していきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/4f7f2a84-8c8c-98a9-9932-91c77ae59e10.png)

設定項目は以下の通りです。

- `Icon Style Key`
タブのアイコンを変更するために使用
（今回はDebugOptionsタブのためのアイコンなので、「Icon_Debug」と入力）
- `Sort Index`
タブが上から何番目に表示されるか
- `Title`
タブの名前（今回は 「Debugコマンド」と入力）
- `Long Title`
スキップでOK
- `Key`
タブのアクティブ・非アクティブを判定するために使用
（今回は「DebugOptions」と入力）

## アイコンの設定

次にタブのアイコンを設定していきます。

最初に、アイコンとして設定したい64*64の画像を用意して、`Assets/StompyRobot/SRDebugger/UI/Sprites/Default/Icons`へ、インポートしてください。

以下は例です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/aa87e05f-a64d-9a94-a1c5-a65fb4f94f74.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/cc31ef92-db0a-7d47-227a-ed4f4b04e080.png)

インポートしたアイコン画像は以下のように設定しておきます。

- `Texture Type`：`Sprite (2D and UI)`
- `Sprite Mode` : 
複数アイコンの集まりの画像なら`Multiple`、ひとつのアイコンのみの画像なら`Single`
- `Pixels Per Unity` : 他のアイコンに合わせて`200`にしておきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/af0783ce-1145-bc36-033a-573b6f538902.png)

次に `Assets/StompyRobot/SRDebugger/UI/Styles/Default.asset` を選択してください。

こちらにはフォントの色や、アイコンなどの見た目に関わる情報が登録されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/bc673c34-67c9-64e7-c432-aadff71fc30d.png)

Inspector上にて`New Style`の項目に、先ほど設定した`Icon Style Key`の値を入力し、Addボタンを押下します。

今回は「Icon_Debug」というStyleを作成しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/86e6f2ad-499e-5126-723d-a995fdb2301d.png)

追加したStyleの一番左に画像を設定するプロパティがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/da885a3e-ef39-562a-ef4c-4f60ff9c3e9c.png)

ここに、先ほどインポートした画像を設定してください。

これでタブ拡張の土台とアイコンの準備は完了です。

## SRDebugger拡張用のベースクラスを用意する

次にタブを自在に拡張するための、ベースクラスを用意していきましょう。

まずはタブの種類を表すEnumを用意します。今回は３種類用意してみました。

```csharp
namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
    public enum DebugTabType
    {
        DebugTab,
        SoundTab,
        TwitchTab,
    }
}
```

それぞれのタブ用にデバッグオプションを追加できるクラスも作成しなければいけません。`TabController`クラス側では、このクラスのインスタンスを経由してオプションの表示などを行っています。

以下の通り、オプション用のBaseクラスを作ります。
中身はオリジナルのSROption `(Assets/StompyRobot/SROptions/SROptions.cs)`と同じです。

```csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
using UnityEngine.Scripting;

public delegate void SROptionsPropertyChanged(object sender, string propertyName);

namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
#if !DISABLE_SRDEBUGGER
    [Preserve]
#endif
    public abstract class SROptionsBase : INotifyPropertyChanged
    {
        public event SROptionsPropertyChanged  PropertyChanged;
        private event PropertyChangedEventHandler InterfacePropertyChangedEventHandler;

#if UNITY_EDITOR
        [JetBrains.Annotations.NotifyPropertyChangedInvocator]
#endif
        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, propertyName);
            InterfacePropertyChangedEventHandler?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }

        event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
        {
            add => InterfacePropertyChangedEventHandler += value;
            remove => InterfacePropertyChangedEventHandler -= value;
        }
    }
}
```

上記クラスを継承した、各タブのオプションクラスもここで作成します。
SROption側として必要な処理と、オプションを実際に追加・削除するためのファイルを分けるために、`partial` を使用しています。

```csharp
namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
    public sealed partial class DebugSROptions : SROptionsBase
    {
        public static DebugSROptions Current { get; } = new();
    }
}
```

オプションを追加するためのコードは以下の通りです。
こちらは上のコードとは別のファイルに分けています。

```csharp
#region

using System.ComponentModel;
using SRDebugger;

#endregion

namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
    /// <summary>
    /// Common debug options.
    /// </summary>
    public partial class DebugSROptions
    {
    
    // ここにオプションを追加していきます。
    // 以下のコードは例となります。
#region Debug Function

        [Category(TestCategory)]
        [DisplayName(TestName)]
        [Increment(0.1)]
        [NumberRange(-10f, 20f)]
        public float TestScale { get; set; }

#endregion

#region Category

        private const string TestCategory = "Test";
        private const string TestName = "Test dayo";

#endregion
    }
}
```

上記の二つのコードは、用意するタブの分だけ必要となります。

今回の場合、上記の`DebugSROptions`以外にも「`SoundSROptions`」「`TwitchSROptions`」を用意しています。
<br>

では、カスタム用`TabController`クラスを用意していきます。名前空間はご自身のプロジェクトに応じて変更をお願いします。また、クラス名はここでは「DebugTabController」として、`SRMonoBehaviorEx`を継承させます。

中身の処理自体はほとんどSRDebuggerのオリジナルコードを写したものとなりますが、一部変更している箇所があります。処理内容を変えずに、コードの書き方だけを変更しているものも含まれています。
それ以外の重要な変更箇所については後程、抜粋して説明を行います。

```csharp
#region

using System;
using System.Collections.Generic;
using System.Linq;
using SRDebugger;
using SRDebugger.Internal;
using SRDebugger.Services;
using SRDebugger.UI.Controls;
using SRDebugger.UI.Controls.Data;
using SRDebugger.UI.Other;
using SRF;
using TwiRoid.Scripts.System.Debug.SRDebugger;
using UnityEngine;
using UnityEngine.UI;

#endregion

namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
    public abstract class DebugTabControllerBase : SRMonoBehaviourEx
    {
        [RequiredField]
        [SerializeField]
        private ActionControl actionControlPrefab;

        [RequiredField]
        [SerializeField]
        private CategoryGroup categoryGroupPrefab;

        [RequiredField]
        [SerializeField]
        private RectTransform contentContainer;

        [RequiredField]
        [SerializeField]
        private GameObject noOptionsNotice;

        [RequiredField]
        [SerializeField]
        private Toggle pinButton;

        [RequiredField]
        [SerializeField]
        private GameObject pinPromptSpacer;

        [RequiredField]
        [SerializeField]
        private GameObject pinPromptText;

        private readonly List<CategoryInstance> _categories = new();

        private readonly List<OptionsControlBase> _controls = new();

        private readonly Dictionary<OptionDefinition, OptionsControlBase> _options = new();

        private bool _isTogglingCategory;
        private Canvas _optionCanvas;

        private bool _queueRefresh;
        private bool _selectionModeEnabled;

        protected override void Start()
        {
            base.Start();

            pinButton.onValueChanged.AddListener(SetSelectionModeEnabled);

            pinPromptText.SetActive(false);

            Populate();

            _optionCanvas = GetComponent<Canvas>();

            Service.Options.OptionsUpdated += OnOptionsUpdated;
            Service.PinnedUI.OptionPinStateChanged += OnOptionPinnedStateChanged;
        }

        protected override void Update()
        {
            base.Update();

            if (!_queueRefresh)
            {
                return;
            }

            _queueRefresh = false;
            Refresh();
        }

        protected override void OnEnable()
        {
            base.OnEnable();

            Service.Panel.VisibilityChanged += OnPanelVisibilityChanged;
        }

        protected override void OnDisable()
        {
            // Always end pinning mode when tabbing away
            SetSelectionModeEnabled(false);

            if (Service.Panel != null)
            {
                Service.Panel.VisibilityChanged -= OnPanelVisibilityChanged;
            }

            base.OnDisable();
        }

        protected override void OnDestroy()
        {
            if (Service.Options != null)
            {
                Service.Options.OptionsUpdated -= OnOptionsUpdated;
            }

            if (Service.PinnedUI != null)
            {
                Service.PinnedUI.OptionPinStateChanged -= OnOptionPinnedStateChanged;
            }

            base.OnDestroy();
        }

        private void SetSelectionModeEnabled(bool isEnabled)
        {
            if (_selectionModeEnabled == isEnabled)
            {
                return;
            }

            _selectionModeEnabled = isEnabled;

            pinButton.isOn = isEnabled;
            pinPromptText.SetActive(isEnabled);

            foreach (var kv in _options)
            {
                kv.Value.SelectionModeEnabled = isEnabled;

                // Set IsSelected if entering selection mode.
                if (isEnabled)
                {
                    kv.Value.IsSelected = Service.PinnedUI.HasPinned(kv.Key);
                }
            }

            foreach (var cat in _categories)
            {
                cat.CategoryGroup.SelectionModeEnabled = isEnabled;
            }

            RefreshCategorySelection();
        }

        private void RefreshCategorySelection()
        {
            _isTogglingCategory = true;

            foreach (var categoryInstance in _categories)
            {
                var allSelected = categoryInstance.Options.All(t => t.IsSelected);
                categoryInstance.CategoryGroup.IsSelected = allSelected;
            }

            _isTogglingCategory = false;
        }

        private void OnCategorySelectionToggle(CategoryInstance category, bool selected)
        {
            _isTogglingCategory = true;

            foreach (var t in category.Options)
            {
                t.IsSelected = selected;
            }

            _isTogglingCategory = false;

            CommitPinnedOptions();
        }

        private void CommitPinnedOptions()
        {
            foreach (var (key, control) in _options)
            {
                switch (control.IsSelected)
                {
                    case true when !Service.PinnedUI.HasPinned(key):
                        Service.PinnedUI.Pin(key);
                        break;
                    case false when Service.PinnedUI.HasPinned(key):
                        Service.PinnedUI.Unpin(key);
                        break;
                }
            }
        }

        private void OnOptionsUpdated(object sender, EventArgs eventArgs)
        {
            Clear();
            Populate();
        }

        private void OnOptionPinnedStateChanged(OptionDefinition optionDefinition, bool isPinned)
        {
            if (_options.TryGetValue(optionDefinition, out var option))
            {
                option.IsSelected = isPinned;
            }
        }

        private void OnOptionSelectionToggle(bool selected)
        {
            if (_isTogglingCategory)
            {
                return;
            }

            RefreshCategorySelection();
            CommitPinnedOptions();
        }

        private void OnPanelVisibilityChanged(IDebugPanelService debugPanelService, bool enable)
        {
            switch (enable)
            {
                // Always end pinning mode when panel is closed
                case false:
                    SetSelectionModeEnabled(false);

                    // Refresh bindings for all pinned controls
                    Refresh();
                    break;
                case true when CachedGameObject.activeInHierarchy:
                    // If the panel is visible, and this tab is active (selected), refresh all the data bindings
                    Refresh();
                    break;
            }

            if (_optionCanvas != null)
            {
                _optionCanvas.enabled = enable;
            }
        }

        private void Refresh()
        {
            for (var i = 0; i < _options.Count; i++)
            {
                _controls[i].Refresh();
                _controls[i].SelectionModeEnabled = _selectionModeEnabled;
                _controls[i].IsSelected = Service.PinnedUI.HasPinned(_controls[i].Option);
            }
        }

        /// <summary>
        /// カスタム Tab 用のメソッド。
        /// カスタム SROption クラスのインスタンスを返却する。
        /// </summary>
        protected abstract SROptionsBase GetSROptionInstance();

        private class CategoryInstance
        {
            public readonly List<OptionsControlBase> Options = new();

            public CategoryInstance(CategoryGroup group)
            {
                CategoryGroup = group;
            }

            public CategoryGroup CategoryGroup { get; }
        }

#region Initialisation

        private void Populate()
        {
            var sortedOptions = new Dictionary<string, List<OptionDefinition>>();

            foreach (var option in SRDebuggerUtil.ScanForOptions(GetSROptionInstance()))
            {
                if (!OptionControlFactory.CanCreateControl(option))
                {
                    UnityEngine.Debug.LogError(option.IsProperty
                                                   ? "[SRDebugger.OptionsTab] Unsupported property type: {0} (on property {1})"
                                                       .Fmt(option.Property.PropertyType, option.Property)
                                                   : "[SRDebugger.OptionsTab] Unsupported method signature: {0}"
                                                       .Fmt(option.Name));
                    continue;
                }

                // Find a proper list for that category, or create a new one
                if (!sortedOptions.TryGetValue(option.Category, out var memberList))
                {
                    memberList = new List<OptionDefinition>();
                    sortedOptions.Add(option.Category, memberList);
                }

                memberList.Add(option);
            }

            var hasCreated = false;

            foreach (var kv in sortedOptions.OrderBy(p => p.Key))
            {
                if (kv.Value.Count == 0)
                {
                    continue;
                }

                hasCreated = true;
                CreateCategory(kv.Key, kv.Value);
            }

            if (hasCreated)
            {
                noOptionsNotice.SetActive(false);
            }

            RefreshCategorySelection();
        }

        private void CreateCategory(string title, List<OptionDefinition> options)
        {
            options.Sort((d1, d2) => d1.SortPriority.CompareTo(d2.SortPriority));

            var groupInstance = SRInstantiate.Instantiate(categoryGroupPrefab);
            var categoryInstance = new CategoryInstance(groupInstance);

            _categories.Add(categoryInstance);

            groupInstance.CachedTransform.SetParent(contentContainer, false);
            groupInstance.Header.text = title;
            groupInstance.SelectionModeEnabled = _selectionModeEnabled;

            categoryInstance.CategoryGroup.SelectionToggle.onValueChanged.AddListener(
                 b => OnCategorySelectionToggle(categoryInstance, b));

            foreach (var option in options)
            {
                var control = OptionControlFactory.CreateControl(option, title);

                if (control == null)
                {
                    UnityEngine.Debug.LogError("[SRDebugger.OptionsTab] Failed to create option control for {0}"
                                                   .Fmt(option.Name));
                    continue;
                }

                categoryInstance.Options.Add(control);
                control.CachedTransform.SetParent(groupInstance.Container, false);
                control.IsSelected = Service.PinnedUI.HasPinned(option);
                control.SelectionModeEnabled = _selectionModeEnabled;
                control.SelectionModeToggle.onValueChanged.AddListener(OnOptionSelectionToggle);

                _options.Add(option, control);
                _controls.Add(control);
            }
        }

        private void Clear()
        {
            foreach (var categoryInstance in _categories)
            {
                Destroy(categoryInstance.CategoryGroup.gameObject);
            }

            _categories.Clear();
            _controls.Clear();
            _options.Clear();
        }

#endregion
    }
}
```

次に、このBaseクラスを継承した `DebugTabController`クラスを用意します。

```csharp
using UnityEngine;

namespace {あなたのプロジェクトの名前空間を使用して下さい}
{
    public sealed class DebugTabController : DebugTabControllerBase
    {
        [SerializeField]
        private DebugTabType debugTabType;

        protected override SROptionsBase GetSROptionInstance()
        {
            return debugTabType switch
            {
                DebugTabType.DebugTab  => DebugSROptions.Current,
                DebugTabType.SoundTab  => SoundSROptions.Current,
                DebugTabType.TwitchTab => TwitchSROptions.Current,
                _                      => DebugSROptions.Current,
            };
        }
    }
}
```

先ほど用意したTabのPrefabにアタッチするのは、こちらのクラスとなります。

### 変更コード

```csharp
private void Populate()
        {
            var sortedOptions = new Dictionary<string, List<OptionDefinition>>();

            foreach (var option in SRDebuggerUtil.ScanForOptions(GetSROptionInstance()))
            {
                if (!OptionControlFactory.CanCreateControl(option))
                {
```

こちらの`Populate()`ですが、もともとのオリジナルコードとほとんど同じ動作をするように用意してあります。ですが、`foreach (var option in SRDebuggerUtil.ScanForOptions(GetSROptionInstance()))`の部分に関しては、変更を加えてあります。

`SRDebuggerUtil.ScanForOptions()`では、引数として渡した`SROption`のインスタンスに基いたオプションを読み取っているため、ここで渡す値は該当のタブと一致する`SROption`クラスのインスタンスにする必要があります。
<br>
これを実現するために、以下のメソッドを用意しました。

```csharp
        /// <summary>
        /// カスタム Tab 用のメソッド。
        /// カスタム SROption クラスのインスタンスを返却する。
        /// </summary>
        protected abstract SROptionsBase GetSROptionInstance();
```

Baseクラスでは仮想メソッド宣言だけを用意しておき、継承先で、設定したタブタイプ（`debugTabType`）に合わせた各タブクラス側のインスタンスを返却するようにしています。

基盤クラスと継承クラスで分けたのは、単純にメンテナンス性を保つためとなります。

SRDebuggr側のオリジナルクラスをそのまま流用する箇所を増やしてしまうと、アップデートが入った際にどこが自分のプロジェクト用に変更したものなのかが、わからなくなってしまいます。

それを防ぐための一つの方法として考えてください。
<br>

コードの準備は、これで十分です。

## Prefabへのアタッチ

先ほど用意したPrefabへ`DebugTabController` をアタッチしてください。そこに元々アタッチされていた`Options Tab Controller`のそれぞれの要素をコピー＆ペーストで、`DebugTabController`のそれぞれの要素にアタッチし直します。`Debug Tab Type`はアタッチしたタブに合わせて変更を行ってください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/90cad9a9-dcf2-28a7-302b-7fc25ed5424f.png)

## 結果

ここまで完了したら、実行してSRDebuggerが正常に開くか、タブが問題なく増えているか確認をしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36899/f454600f-eae9-9662-a07b-9aac28ac7528.png)

意図した通りにタブが増えていれば成功です。

お疲れ様でした！