7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一人ゲーム開発TipsAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-12-04

最初に

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

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

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

SRDebuggerとは何か?

image.png

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

詳しい動作例は公式を見て頂くほうが早いでしょう。

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

SRDebugger | Stompy Robot


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

image.png

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

【Unity】SRDebuggerの使い方と拡張方法の紹介 - Qiita

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

タブの拡張方法

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

タブ用のPrefab作成

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

内部実装について、SRDebuggerのコード内ではAssets/StompyRobot/SRDebugger/Resources/SRDebugger/UI/Prefabs/Tabs内にあるPrefabをタブとして扱い、生成する形になっています。

image.png

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

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

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

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

  • DebugOptions
  • SoundOptions
  • TwitchOptions

image.png

各タブへの設定

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

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

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

image.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

image.png

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

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

image.png

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

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

image.png

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

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

image.png

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

image.png

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

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

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

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

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

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

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

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

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 を使用しています。

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

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

#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」を用意しています。

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

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

#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クラスを用意します。

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にアタッチするのは、こちらのクラスとなります。

変更コード

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クラスのインスタンスにする必要があります。


これを実現するために、以下のメソッドを用意しました。

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

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

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

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

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

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

Prefabへのアタッチ

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

image.png

結果

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

image.png

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

お疲れ様でした!

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?