最初に
本記事は、SRDebuggerを用いて、Unity上でデバッグシステムを構築するためのテクニックをまとめたものとなります。SRDebuggerを使用しているプロジェクトのエンジニアさんへの一助となれば幸いです。
本記事内での環境は以下の通りです。
使用技術 | バージョン |
---|---|
Unity | 2023.2.15f1 |
SRDebugger | 1.12.1 |
SRDebuggerとは何か?
- Importするだけで、ランタイム上で動作するコンソール画面から、ゲームに関する様々なログが確認可能。UnityのDebug APIで出力されたログも表示されるので、実機上でも便利。
- セットアップも、ガイドに従うだけで、簡単にタブの無効化や、キーボードショートカットキーの変更などが行える。
- デフォルトで用意されているコンソールのタブからシステム情報や、プロファイラーグラフでのパフォーマンス計測をすることもできる。
- パネルをカスタマイズすることで、独自のデバッグコマンドを簡単に用意することも可能。
詳しい動作例は公式を見て頂くほうが早いでしょう。
(英語が読めなくても埋め込みのgifを見るだけで雰囲気がつかめます)
以下の画像は、私個人のプロジェクト用に改造したものです。
以下に挙げる基本的な使い方を確認する際は、こちらの記事を参考にするのがおすすめです。
【Unity】SRDebuggerの使い方と拡張方法の紹介 - Qiita
- 導入方法
- 設定の変更
- 各タブの表示確認
- オプションタブへの簡易的なカスタムデバッグ機能追加
タブの拡張方法
それでは今回の本題である、タブの拡張手法を見ていきましょう。
タブ用のPrefab作成
Assets/StompyRobot/SRDebugger/Resources/SRDebugger/UI/Prefabs/Tabs/Options.prefab
を複製してタブを追加します。
内部実装について、SRDebuggerのコード内ではAssets/StompyRobot/SRDebugger/Resources/SRDebugger/UI/Prefabs/Tabs
内にあるPrefabをタブとして扱い、生成する形になっています。
今回は私が現在開発しているプロジェクトをそのまま流用して作っていきます。
そのため、以下の三つのタブを用意することにしましょう。
- 汎用デバッグシステム用
- サウンドデバッグ用
- Twitchとの通信デバッグ用
Options
というPrefabをOriginalPrefabとして扱い、そこから3つ複製してください。そこからそれぞれのPrefabに以下の名前をつけます。
- DebugOptions
- SoundOptions
- TwitchOptions
各タブへの設定
次に、それぞれのPrefab用に設定を変更していきます。
Prefabを選択してInspectorを確認すると、SR Tab
というスクリプトがアタッチされているのがわかると思います。そちらのパラメータを変更することで、タブ名やアイコンを変えることができます。
それでは、さっそく変更していきましょう。
設定項目は以下の通りです。
-
Icon Style Key
タブのアイコンを変更するために使用
(今回はDebugOptionsタブのためのアイコンなので、「Icon_Debug」と入力) -
Sort Index
タブが上から何番目に表示されるか -
Title
タブの名前(今回は 「Debugコマンド」と入力) -
Long Title
スキップでOK -
Key
タブのアクティブ・非アクティブを判定するために使用
(今回は「DebugOptions」と入力)
アイコンの設定
次にタブのアイコンを設定していきます。
最初に、アイコンとして設定したい64*64の画像を用意して、Assets/StompyRobot/SRDebugger/UI/Sprites/Default/Icons
へ、インポートしてください。
以下は例です。
インポートしたアイコン画像は以下のように設定しておきます。
-
Texture Type
:Sprite (2D and UI)
-
Sprite Mode
:
複数アイコンの集まりの画像ならMultiple
、ひとつのアイコンのみの画像ならSingle
-
Pixels Per Unity
: 他のアイコンに合わせて200
にしておきます
次に Assets/StompyRobot/SRDebugger/UI/Styles/Default.asset
を選択してください。
こちらにはフォントの色や、アイコンなどの見た目に関わる情報が登録されています。
Inspector上にてNew Style
の項目に、先ほど設定したIcon Style Key
の値を入力し、Addボタンを押下します。
今回は「Icon_Debug」というStyleを作成しました。
追加したStyleの一番左に画像を設定するプロパティがあります。
ここに、先ほどインポートした画像を設定してください。
これでタブ拡張の土台とアイコンの準備は完了です。
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
はアタッチしたタブに合わせて変更を行ってください。
結果
ここまで完了したら、実行してSRDebuggerが正常に開くか、タブが問題なく増えているか確認をしてください。
意図した通りにタブが増えていれば成功です。
お疲れ様でした!