UI Toolkitで自作するUnityランタイムデバッグツールの設計と実装

目次

はじめに

以前の記事で、サムザップの Unity 向けアプリケーション基盤『Spica』の全体像を紹介しました。今回はその中のひとつ、Spica.Debugger にフォーカスし、設計判断と内部実装の詳細を掘り下げます。

Spica.Debugger は UI Toolkit ベースのランタイムデバッグツールです。Console(ログビューア)、Profiler(FPS/メモリ監視)、System Info(デバイス情報)、Report(バグレポート)、Options(カスタムデバッグメニュー)の5つの機能を備えています。ランタイムとエディタの両方で動作するよう設計されています。

この記事では Spica.Debugger の設計と実装を実コードを交えて解説します。具体的には次のテーマを扱います。

  • なぜ自作したのか
  • なぜ UI Toolkit を選んだのか
  • ScriptableObject ベースの設定・拡張アーキテクチャ
  • ページベースの内部構造
  • エディタとランタイムの連携

Spica Debugger のランタイムでの動作

なぜ自作したのか

サムザップでは、もともとサードパーティの SRDebugger を利用していました。しかし開発を始めた当時には SRDebugger の更新が長期間停止しており、新しい Unity バージョンでは警告が出る状況になっていました。加えて、バグレポートを Slack や Wrike へ振り分けたい・複数の起動方法を端末ごとに使い分けたい・例外を自動送信したい、といった独自要件が社内プロジェクト側で積み上がっており、サードパーティ製ツールでは内部に手を入れずに対応するのが難しい状況でした。

そこで、UI Toolkit を採用した自前のランタイムデバッグツールとして Spica.Debugger を構築しました。Unity 追従を内製で回しつつ、プロジェクト固有の拡張を共通基盤を壊さずに差し込める設計を狙っています。

UI Toolkit を採用した理由

uGUI ではなく UI Toolkit を選んだのは、ランタイムデバッガとしての要件にマッチする点が多かったためです。

ゲーム本体の UI と独立して動かせる

UI Toolkit はゲーム本体の uGUI とは別の PanelSettings として描画されます。Spica.Debugger は Sorting Order を上げることでゲーム本体の Canvas より手前へデバッガを描画し、タップも UI Toolkit 側が先に受け取れる構成です。

USS によるスタイル管理

UI Toolkit は USS(Unity Style Sheets)で CSS ライクにスタイルを管理できます。テーマカラーの切り替え、フォントサイズの一括変更、状態に応じた見た目の変化などを宣言的に書けるため、保守しやすい構造になります。

Spica.Debugger では、この USS を DebuggerSettingsStyleSheet[] フィールド経由で外から差し替えられる 構成にしています。

[SerializeField] private StyleSheet[] _debuggerViewStyles;
[SerializeField] private StyleSheet[] _overlayViewStyles;

ランタイム側は、この配列を順に root.styleSheets.Add するだけのシンプルな実装です。

private static void ApplyStyleSheets(VisualElement root, StyleSheet[] styleSheets)
{
    foreach (var styleSheet in styleSheets)
    {
        if (styleSheet != null && !root.styleSheets.Contains(styleSheet))
            root.styleSheets.Add(styleSheet);
    }
}

USS の cascade ルールに従って、後ろに Add したものほど優先される仕組みです。標準テーマを Spica.Debugger 側で配り、プロジェクト固有の上書き USS を末尾へ足すだけで、.debugger-cell.debugger-button のようなビルトインクラスを安全に再着色できます。

/* 例: 既存のセルとボタンの色だけ差し替える */
.debugger-cell   { background-color: rgba(50, 50, 60, 0.5); }
.debugger-button { background-color: rgba(80, 120, 200, 0.8); }

プロジェクト独自のセル/ページ用カスタムクラスを定義した USS も、同じ配列へ放り込むだけで自動的に適用されます。これによって、見た目の調整や独自 UI の追加が USS 1 枚を増やすだけで完結する設計です。

ListView の仮想化

Console 機能では、数千件のログを快適にスクロールする必要があります。UI Toolkit の ListView画面に映る分しか VisualElement を生成しない 仮想化に対応しており、makeItem でテンプレートを 1 度だけ生成し、bindItem で表示中の行にデータを差し替える方式で動きます。

Spica.Debugger の Console ページでは次のように構成しています。

private ListView CreateLogListView()
{
    var listView = new ListView
    {
        makeItem    = MakeLogItem,
        bindItem    = BindLogItem,
        itemsSource = _filteredLogs,
        fixedItemHeight      = ConsoleLogItem.BaseHeight,
        virtualizationMethod = CollectionVirtualizationMethod.FixedHeight,
    };
    return listView;
}

private VisualElement MakeLogItem() => new ConsoleLogItem();

private void BindLogItem(VisualElement element, int index)
{
    ((ConsoleLogItem)element).Bind(_filteredLogs[index]);
}

virtualizationMethod = FixedHeightfixedItemHeight の組み合わせがポイントです。行ごとに高さを measure する必要がなくなり、ログが数千件出ても要素数は画面に映る数十個分しか存在しません。itemsSource の中身を差し替えると ListView 側で差分だけ再バインドしてくれるため、フィルタ変更時の再描画コストも極小に抑えられます。

Editor / Runtime 両対応

UI Toolkit で構築した VisualElement は、Editor と Runtime のどちらでも動かせます。Spica.Debugger では同一の DebuggerView インスタンスを EditorWindow にも配置できるよう設計しており、これがエディタ連携を非常にシンプルにしています。詳細は「Editor Window で動かす」のセクションで扱います。

PanelSettings の分離

Spica.Debugger では PanelSettingsDebuggerSettings ScriptableObject に持たせています。ゲーム本体側で別の PanelSettings を使っていても干渉せず、Sorting Order だけで前面に出すかを制御できる構成です。

ScriptableObject ベースの設定アーキテクチャ

Spica.Debugger の設計の中心にあるのは、「設定をすべて ScriptableObject に集約し、拡張対象も ScriptableObject 参照として差し込む」 という方針です。これにより、Inspector を開けば設定全体を俯瞰でき、拡張要素は自由に差し替えられます。

Setup Wizard で初期構築を自動化

導入時の初期構築には Setup Wizard を用意しています。

Spica Debugger Setup Wizard。利用する Trigger と Report Sender を選択するだけで必要な ScriptableObject 一式が自動生成される

利用したい Trigger と Report Sender、Auto Exception Report の送信先を選択し、Enable & Create Assets を押すだけで導入が完了します。SPICA_DEBUGGER_ENABLED シンボルの設定と、必要な ScriptableObject の生成までが1アクションで行われます。

DebuggerSettings に集約された設定

すべての設定は DebuggerSettings という1つの ScriptableObject に集約されています。

DebuggerSettings の Inspector 上半分。UI Assets / Triggers / Console / Profiler View / Report Foldout 冒頭が並ぶ

Inspector の上半分には次の項目が並びます。

  • UI Assets(PanelSettings / Sorting Order)
  • Debugger View Styles / Overlay View Styles
  • Icons
  • Triggers(起動トリガーの ScriptableObject 配列)
  • Console
  • Profiler View
  • Report Foldout 冒頭の Description Format / Reporter Assignee Names

DebuggerSettings の Inspector 下半分。Report Senders、Auto Exception Report、InstantReplay の各セクションが並ぶ

Inspector の下半分には次の項目が並びます。

  • Report セクション内の Report Senders(送信先の ScriptableObject 配列)
  • Auto Exception Report セクション(Senders / Cooldown / Include in Report トグル群)
  • InstantReplay セクション(Video Settings / Audio Settings)

Inspector を上下に眺めるだけで、デバッガの全体像が一目で把握できます。起動トリガー・Report 構成・送信先がまとめて見える設計です。

エントリポイントは1行

利用側のコードは、DebuggerSettingsDebugger.Initialize に渡すだけです。

using UnityEngine;
using Spica.Debugger;

public sealed class DebuggerBoot : MonoBehaviour
{
    [SerializeField] private DebuggerSettings _settings;

    private void Awake()
    {
        Debugger.Initialize(_settings);
    }
}

これだけでログ収集、Trigger の起動監視、自動例外レポート、InstantReplay 録画など、DebuggerSettings で有効にした機能がすべて立ち上がります。利用側のコードは1行で済むため、ブートシーケンスを汚しません。

拡張ポイント: Trigger

Trigger(デバッガの起動方法)は、すべて ScriptableObject として実装されています。DebuggerSettings.Triggers 配列に並べた ScriptableObject が、起動時に有効な Trigger として登録される仕組みです。

抽象クラス: TriggerBase

すべての Trigger は TriggerBase を継承しています。

public abstract class TriggerBase : ScriptableObject
{
    public abstract string DisplayName { get; }
    public bool IsEnabled { get; set; } = true;

    public event Action OnTriggered;

    public virtual void Initialize(DebuggerSettings settings, VisualElement overlayRoot) { }
    public virtual void Update() { }
    public virtual void Dispose() { }

    protected void Trigger() => OnTriggered?.Invoke();
}

ライフサイクル(Initialize / Update / Dispose)と、発火を通知する Trigger() メソッドが提供されます。Update は毎フレーム呼ばれるため、入力監視やジェスチャ判定もこの中で行います。

同梱の Trigger 実装

Spica.Debugger には標準で6種類の Trigger を同梱しています。代表的な3つを紹介します。

Floating Button

FloatingButtonTrigger の Inspector。Button Size と初期位置(Button Position)のみのシンプルな設定

画面に常時表示されるフローティングボタンをタップで起動します。Button Position で四隅のいずれかを初期位置として指定し、実行中はドラッグで任意の場所へ自由に移動できます。設定は Button SizeButton Position のみとシンプルな構成です。

Keyboard

KeyboardTrigger の Inspector。Shortcut Keys 配列で組み合わせを設定(既定は Left Ctrl + Left Alt + D)

ショートカットキーの同時押しで起動します。デフォルトは Left Ctrl + Left Alt + D ですが、Shortcut Keys 配列に任意のキーを並べることで自由にカスタマイズできます。エディタや PC ビルドでの動作確認に便利な Trigger です。

Corner Long Press

CornerLongPressTrigger の Inspector。Target Corner / Corner Size / Hold Duration Seconds / Indicator Color を持つ

画面の角を指定秒数だけ長押しすると起動します。Target Corner(四隅のどこか)、Corner Size(反応領域のサイズ)、Hold Duration SecondsIndicator Color(押下中のインジケータの色)を設定できます。タッチ操作を妨げずに、QA メンバーに「ここを押し続けてね」と案内するのも簡単です。

このほか、Triple Touch(3本指タップ)、Pinch(ピンチイン)、Corner Triple Tap(角を3回タップ)が同梱されています。

独自 Trigger の追加

TriggerBase を継承して [CreateAssetMenu] を付ければ、独自の Trigger を ScriptableObject として作成できます。あとは DebuggerSettings.Triggers に登録するだけで有効化されます。

たとえば、端末を振ったらデバッガが開く ShakeTrigger は、Input System の Accelerometer を使って以下のように書けます。

using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
using Spica.Debugger;

[CreateAssetMenu(menuName = "MyProject/Debugger/Triggers/Shake Trigger")]
public sealed class ShakeTrigger : TriggerBase
{
    [SerializeField] private float _threshold = 2.5f;

    public override string DisplayName => "Shake";

    public override void Initialize(DebuggerSettings settings, VisualElement overlayRoot)
    {
        if (Accelerometer.current != null)
            InputSystem.EnableDevice(Accelerometer.current);
    }

    public override void Update()
    {
        if (!IsEnabled || Accelerometer.current == null) return;

        var sqr = Accelerometer.current.acceleration.ReadValue().sqrMagnitude;
        if (sqr > _threshold * _threshold)
            Trigger();
    }

    public override void Dispose()
    {
        if (Accelerometer.current != null)
            InputSystem.DisableDevice(Accelerometer.current);
    }
}

プロジェクト固有の入力デバイスやジェスチャに合わせて、いくらでも自由に増やせる設計です。

拡張ポイント: Report Sender

Report Sender(バグレポートの送信先)も Trigger と同じ思想で、ScriptableObject 継承の差し替え可能な拡張ポイントとして実装されています。

抽象クラス: ReportSenderBase

すべての Report Sender は ReportSenderBase を継承します。

public abstract class ReportSenderBase : ScriptableObject, IReportSender
{
    public abstract string DisplayName { get; }
    public abstract Task<bool> SendAsync(ReportData data);
}

SendAsync に渡される ReportData には、タイトル、説明、報告者、担当者、スクリーンショット、Console Log、System Info、(オプションで)動画などが含まれます。送信先に応じて整形して送信するだけです。

なお、プロジェクト側に UniTask(com.cysharp.unitask)がインストールされている場合は、UniTask<bool> を返すシグネチャへ自動で切り替わります。Spica.Debugger.asmdefversionDefines が UniTask の存在を検知し、SPICA_DEBUGGER_UNITASKUnity 側で自動定義 するため、Scripting Define Symbols へ手動追加する必要はありません。

同梱の Report Sender

Slack

SlackReportSender の Inspector。Bot Token と Channel Id、ユーザーマッピング、補助ユーティリティを備える

Slack の Bot Token と Channel Id を設定し、報告者・担当者を User Mappings で Slack ユーザー ID とひもづければ、メンション付きでレポートを送信できます。Fetch User Ids のユーティリティボタンで、登録名から Slack のユーザー ID を自動取得できます。

Bot Token はビルド時のコマンドライン引数で注入する方式と、ローカル開発時に PlayerPrefs へ保存する方式の両方をサポートしています。機密情報をリポジトリへコミットせずに運用できる構成です。

Wrike

WrikeReportSender の Inspector。Access Token と Folder Id、ユーザーマッピング、補助ユーティリティを備える

Wrike の Access Token と Folder Id を設定すれば、レポートを Wrike のタスクとして起票できます。Get Folder Id from Permalink はフォルダの URL と Access Token を入力し、Wrike API 経由で Folder Id を取得する補助機能です。Fetch Contact Ids も同じく API 経由でユーザー ID を取得できます。

独自 Report Sender の追加

社内チャットツールへの送信や独自のチケット管理システムへの起票も、ReportSenderBase を継承するだけで対応できます。

using System.Threading.Tasks;
using UnityEngine;
using Spica.Debugger;

[CreateAssetMenu(menuName = "MyProject/Debugger/Report Senders/My Chat Sender")]
public sealed class MyChatReportSender : ReportSenderBase
{
    public override string DisplayName => "MyChat";

    public override async Task<bool> SendAsync(ReportData data)
    {
        // data.Title / data.Description / data.Screenshot / data.SystemInfo / data.ConsoleLog などをペイロードに詰めて送信
        return await MyChatClient.PostAsync(data);
    }
}

作成した ScriptableObject を DebuggerSettings.ReportSenders に追加すれば、Report タブの送信先チェックボックスに自動で表示されます。

自動例外レポート(Auto Exception Report)

未処理例外の発生時に、自動でレポートを送信する仕組みも用意しています。実装は Application.logMessageReceived をフックする ConsoleLogCollector と、その派生イベントを購読する ExceptionReporter の 2 段構成です。

// ConsoleLogCollector: Console と自動例外レポートの両方の入口
Application.logMessageReceived += OnLogMessageReceived;

private void OnLogMessageReceived(string message, string stackTrace, LogType logType)
{
    lock (_lock)
    {
        var log = new ConsoleLogEntry(message, stackTrace, logType);
        _logs.Add(log);
        OnLogReceived?.Invoke(log);   // ← ExceptionReporter 等が購読
        TrimLogs();
    }
}
// ExceptionReporter: LogType.Exception だけを拾ってクールダウン付きで送信
private void OnLogReceived(ConsoleLogEntry entry)
{
    if (entry.LogType != LogType.Exception) return;

    var now = Time.realtimeSinceStartup;
    if (now - _lastReportTime < _settings.AutoExceptionReportCooldown) return;
    if (_isSending) return;

    _lastReportTime = now;
    _ = SendReportAsync(entry); // fire-and-forget で全 Senders に並列投下
}

Application.logMessageReceived をフックしているのがポイントです。Unity 内部や Debug.LogExceptiontrycatch を抜けた未処理例外まで、LogType.Exception として全部ここに流れ込みます。ConsoleLogCollector を 1 つ用意するだけで、Console UI と自動例外レポートの両方が同じソースから派生する設計です。

通常レポートとは別の Senders 配列を持つため、「手動レポートは Slack と Wrike の両方に、自動レポートはエラー専用 Slack チャンネルにだけ送る」といった分配もできます。短時間に大量の例外が連続発生する状況に備えて、Cooldown (seconds) の経過時間差で間引きが入る構造です。

自動レポートに添付する項目は、DebuggerSettings の Auto Exception Report セクション内 Include in Report トグルで指定します。Screenshot / Console Log / System Info / Video を個別に選択できます。

リプレイ動画の添付(InstantReplay 連携、オプション)

Spica.Debugger は、動画レポート機能を CyberAgentGameEntertainment/InstantReplay との連携によって オプションで 提供しています。InstantReplay は CyberAgent のゲーム・エンタテイメント事業部が公開している OSS で、最新数十秒のゲームプレイをリングバッファに保持し、必要なタイミングで動画ファイルとして書き出せるライブラリです。バグ発生時の再現手順記録にとても相性が良い仕組みです。

導入は Package Manager に InstantReplay の Git URL を追加するだけです。Spica.Debugger.asmdefversionDefinesjp.co.cyberagent.instant-replay を検知すると、SPICA_DEBUGGER_INSTANTREPLAY が自動定義されます。Scripting Define Symbols を触る必要はありません。

導入後は InstantReplayHandlerRealtimeInstantReplaySession をラップし、リングバッファ録画を自動で開始します。Video Settings(Scale / FPS / Bitrate / Max Memory)と Audio Settings(Sample Rate / Channels / Bitrate)は、すべて DebuggerSettings 側でまとめて制御します。

Auto Exception Report と組み合わせると、例外発生時に直前のプレイ動画が自動添付された状態でレポートが送られます。手動レポート時は Report タブ内 Attachments セクションの Include Video トグルで添付可否を選べます(Inspector の Include in Report とは別の、ReportPage 側のトグルです)。録画の開始・停止を細かく制御したい場合は、Debugger.StartRecording() / Debugger.StopRecording() / Debugger.IsRecording の API を利用できます。

InstantReplay を導入しない場合

InstantReplay は オプションの連携機能 であり、未導入のままでもレポート機能は問題なく動作します。SPICA_DEBUGGER_INSTANTREPLAY シンボルを定義していなければ、動画関連のコードはコンパイルから完全に外れます。

動画が添付できない代わりに、以下の情報はそのままレポートに添付できます。

  • スクリーンショット: バグ発生時の画面を1枚キャプチャして添付(標準機能)
  • Console Log: その時点までのログ履歴
  • System Info: デバイス情報、アプリ情報、カスタムシステム情報

「動画は欲しいが導入コストが気になる」「対応プラットフォームを絞っているのでまずスクリーンショット運用で始めたい」というプロジェクトでも問題ありません。まずスクリーンショット運用で運用を立ち上げ、必要になったタイミングで InstantReplay を後から追加する、という段階導入が可能です。

ページベースの UI 構造

Spica.Debugger の UI は、すべて「ページ」を単位として構成されています。Console / Profiler / System Info / Report / OptionContainer がそれぞれ独立した DebugPageBase 派生として実装されており、新しいページの追加もこのクラスを継承するだけで実現できます。

DebugPageBase と DebugPageBuilder

DebugPageBaseVisualElement を継承した抽象クラスで、ページのライフサイクル(Activate / Deactivate)、再構築(Rebuild)、サブページへのナビゲーション(NavigateToPageLink)を備えています。

ページの中身を組み立てるための拡張メソッド群が DebugPageBuilder に集約されています。AddSection / AddLabel / AddButton / AddSlider / AddToggle / AddDropdown / AddInputField / AddMaskField / AddPageLink といったメソッドが用意されています。

public static class DebugPageBuilder
{
    public static LabelCell AddLabel(this VisualElement container, string title, Func<string> valueGetter)
    {
        var cell = new LabelCell(title, valueGetter);
        container.Add(cell);
        return cell; // 追加したセルを返し、続けて RefreshEvery などを呼べる
    }

    public static SliderCell AddSlider(this VisualElement container,
        string title, float min, float max, Func<float> getter, Action<float> setter)
    {
        var cell = new SliderCell(title, min, max, getter, setter);
        container.Add(cell);
        return cell;
    }
    // AddSection / AddButton / AddToggle ... 他も同じパターン
}

VisualElementthis に取る拡張メソッドとして書かれているため、利用側は container.AddSlider(...) のように どんな VisualElement 派生型にも自然に生やせる 形になっています。戻り値が VisualElement ではなく具体的なセル型なので、追加直後にそのセル自身へ追加設定を続けて書けるのも特徴です。

container.AddLabel("FPS", () => GetFps().ToString())
         .RefreshEvery(intervalMs: 500);  // CellBase.RefreshEvery は this を返す

プロジェクト固有のセル(グラフセルやトースト通知セルなど)が必要になったら、CellBase を継承して同じ拡張メソッドパターンを足すだけで、同じ DSL の中に組み込めます。

独自オプションタブの追加

プロジェクト固有のデバッグメニューは、Debugger.AddOptionsTab で追加します。

Debugger.AddOptionsTab("Dev", () => new MyDevPage());

public sealed class MyDevPage : DebugPageBase
{
    protected override void Build(VisualElement container)
    {
        container.AddSection("Player");
        container.AddSlider("HP", 0, 9999, () => Player.HP, v => Player.HP = (int)v);
        container.AddToggle("God Mode", () => Player.GodMode, v => Player.GodMode = v);
        container.AddButton("Add 1000 Coins", () => Player.Coins += 1000);
    }
}

これだけで Options タブの中に「Dev」サブタブが追加されます。中身には HP スライダー、God Mode トグル、コイン付与ボタンが並びます。AddSlider などのセルはゲッターとセッターのデリゲートで状態を双方向にバインドするため、利用側のコードがとてもシンプルになります。

パスベースのナビゲーション

Debugger.Open はパス文字列を受け取って、特定のページまで一気に遷移できます。

// 例: Options タブ → Dev サブタブ → "Player" リンクの先まで一気に遷移
Debugger.Open("Options/Dev/Player");

中の解決ロジックはシンプルで、パスを / で分割し、先頭からタブ・サブタブ・PageLink の順に消費していきます。

var parts = path.Split('/');

// 1. 先頭はタブ名: View.SelectTab で切り替え
View.SelectTab(parts[0], forceReset: true, notify: !hasOptionsSubTab);

// 2. Options タブのみサブタブを持つので parts[1] を OptionContainer に流す
if (parts[0] == "Options" && parts.Length >= 2)
    optionContainer.SelectTab(parts[1]);

// 3. 残り (parts[2..]) は現在のページ上の PageLink タイトルとして解決
for (int i = startIndex; i < parts.Length; i++)
{
    if (!currentPage.NavigateToPageLink(parts[i]))
        return; // 見つからなければ警告を出して終了

    currentPage = s_controller.CurrentPage;
}

NavigateToPageLink の実装は、DebugPageBuilder.AddPageLink で配置された PageLinkCellContainer から再帰的に検索し、見つかったセルに対して ClickEvent を擬似発火する形になっています。これにより「人間がクリックしたときと完全に同じ遷移コード」を通るため、特別な分岐を増やさずに任意の階層へジャンプできます。

QA に案内する URL/URI スキーム/開発用バッチ起動など、外部から特定画面を直接開かせたいケースで使い勝手の良い API です。

カスタムシステム情報の追加

System Info タブには、ランタイムから動的・静的な追加情報を登録できます。

// 静的な値(一度設定したら変わらない)
Debugger.AddCustomSystemInfo("Build Hash", BuildInfo.GitHash);

// 動的な値(参照時に毎回 valueGetter が呼ばれる)
Debugger.AddCustomSystemInfo("Current Server", () => NetworkManager.CurrentServer);
Debugger.AddCustomSystemInfo("Logged In User", () => UserSession.UserId ?? "(none)");

// 不要になったら削除
Debugger.RemoveCustomSystemInfo("Logged In User");

サーバー切り替え、ログイン中のユーザー ID、ビルドハッシュなど、プロジェクト固有の状態を System Info タブへ即座に出せるため、QA や調査時に役立ちます。

独自セルの作成

AddSliderAddToggle といった同梱セルで足りない場合は、CellBase を継承して独自セルを作成できます。

public abstract class CellBase : VisualElement
{
    public abstract string Title { get; }

    public virtual void Refresh() { }

    public CellBase RefreshEvery(long intervalMs = 1000)
    {
        schedule.Execute(Refresh).Every(intervalMs);
        return this;
    }
}

RefreshEvery を使えば、たとえば「サーバの Tick 数」のようなリアルタイム情報を1秒ごとに自動更新するセルが簡単に作れます。同梱セルと同じスタイル(USS)が当たるため、見た目の統一感を保ったまま機能を増やせる構成です。

Editor Window で動かす

ここまで紹介した UI Toolkit ベースの構成には、もう1つ大きな恩恵があります。ランタイムで動いているのと同じデバッガを、Editor Window 内に表示して操作できる という点です。

EditorWindow に表示された Spica Debugger。Options/Settings タブの複数セクションが見える

仕組み

エディタメニューの Window > Spica > Debugger > Open から DebuggerEditorWindow を開きます。ランタイム側で Debugger.Initialize 済みであれば、Debugger.ViewDebuggerView インスタンス)をそのまま EditorWindow.rootVisualElementAdd する、というシンプルな実装です。

public sealed class DebuggerEditorWindow : EditorWindow
{
    [MenuItem("Window/Spica/Debugger/Open")]
    public static void Open()
    {
        var window = GetWindow<DebuggerEditorWindow>();
        window.titleContent = new GUIContent("Debugger");
        window.Show();
    }

    private void UpdateView()
    {
        rootVisualElement.Clear();

        if (!Debugger.IsInitialized)
        {
            ShowNotInitializedMessage();
            return;
        }

        var view = Debugger.View;
        view.RemoveFromHierarchy();
        view.style.flexGrow = 1;
        rootVisualElement.Add(view);
    }
}

同じ VisualElement インスタンスを Game View ではなく EditorWindow の階層に付け替えるだけで、ロジック・状態・スタイルがすべて共有されます。これは UI Toolkit ならではの強みです。Play / Edit モード遷移時にはビューを再アタッチし、ウィンドウを閉じる際は元の階層に戻す処理も組み込まれています。

ユースケース

同じ VisualElement インスタンスを使い回すことで、Game View でゲームを操作しながら別ウィンドウで HP 変更・コイン付与・ガチャ確率調整などのデバッグ操作を並行できます。スマートフォン解像度の Game View では狭くて触りにくい UI も、Editor Window 側なら広く表示できますし、Console や Profiler のような監視系ページをゲーム本体の表示を遮らず常時開いておく使い方も可能です。ランタイムビューと完全に同じ UI が動くため、エディタ上で挙動を確認したあと、そのまま実機ビルドでも違和感なく利用できます。

おわりに

この記事では、Spica.Debugger の設計と実装の詳細を紹介しました。設計のキーポイントを改めてまとめます。

  • UI Toolkit を採用 したことで、ゲーム本体の uGUI と干渉しない独立 UI 層を構築できた
  • 同一の VisualElement を Editor / Runtime で共有 できるため、EditorWindow からの並行デバッグも実現できた
  • 設定をすべて ScriptableObject に集約 し、Inspector から全体を俯瞰できる構成にした
  • Trigger / ReportSender / DebugPage の3つの拡張ポイント で、プロジェクト固有の要件に対応できる
  • Debugger.Open(path) によるパスベースナビゲーションで、深い階層も1行で開ける
  • Setup Wizard と Define Symbol で導入のハードルを下げつつ、リリースビルドからは完全に切り離せる

Unity 標準やサードパーティのデバッグツールに頼らず、必要な拡張ポイントだけを自前で設計することで、共通基盤としての発展余地と、プロジェクト固有要件への対応力を両立しています。


s07452
中北 龍秀

株式会社サムザップ クライアントエンジニア 2019年入社
『呪術廻戦 ファントムパレード』や『真 戦国炎舞 -KIZNA-』の開発を経て
現在は開発推進室のアプリケーション基盤と新規プロジェクトの開発を担当