【Unity】【エディタ拡張】関連するTooltipにリンクできるTooltip「BalloonHelp」

Unityでインスペクタに表示する変数のヘルプをTooltipを使って書くことがあると思います。

f:id:halya_11:20180809233416p:plain:w400

これあまり見ないし、そもそも設定されているかどうかわかりづらすぎませんか?
という不満が前々からあったので独自のTooltipを軽く作ってみました。

作ったもの

こんな感じのものを作りました。

その変数のヘルプのほか、関連するヘルプがあればリンクが表示されます。
シャドウバースのゲーム中に出るヘルプが非常に好きなのでそんなイメージで作りました。

f:id:halya_11:20180809234640p:plain:w300

使い方

1.上部メニューのBalloon Help > Create SourceからデータソースのScriptableObjectを生成します
2.データを入力します。説明文中に[他のHelpのID]と表記すると他のHelpにリンクされます

f:id:halya_11:20180809234842p:plain:w300

3.BalloonHelpアトリビュートをつけたシリアライズフィールドを定義します

[BalloonHelp("actor_prefab_id"), SerializeField]
private int _actorPrefabId;

これだけで、あとはいい感じに表示してくれます。

ソースコード(Window)

まずデータソースのScriptableObjectから。

#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

public class BalloonHelpSource : ScriptableObject, ISerializationCallbackReceiver
{
    /// <summary>
    /// ヘルプ内容
    /// </summary>
    [System.Serializable]
    public class Help
    {
        public string id;
        public string title;
        [TextArea(5, 50)]
        public string description;
        [System.NonSerialized]
        public List<string> relationIds = new List<string>();
    }

    /// <summary>
    /// シングルトン
    /// </summary>
    public static BalloonHelpSource Instance {
        get {
            if (_instance == null) {
                _instance = AssetDatabase.FindAssets("t:" + typeof(BalloonHelpSource).Name)
                 .Select(guid => AssetDatabase.LoadAssetAtPath<BalloonHelpSource>(AssetDatabase.GUIDToAssetPath(guid)))
                 .FirstOrDefault();
            }
            return _instance;
        }
    }
    private static BalloonHelpSource  _instance = null;

    [SerializeField]
    private List<Help> _helps = new List<Help>();
    private Dictionary<string, Help> _helpMap = new Dictionary<string, Help>();
    
    /// <summary>
    /// Helpを取得する
    /// </summary>
    public Help GetHelp(string id, bool createRelations = true)
    {       
        // Helpを取得 / 存在しなかったらnullを返す
        if (!_helpMap.ContainsKey(id)){
            return null;
        }
        var ret = new Help();
        var help = _helpMap[id];
        ret.id = help.id;
        ret.title = help.title;
        ret.description = help.description;

        // Relationsの構築
        if (createRelations)
        {
            var matches = Regex.Matches(help.description, @"\[[a-z0-9A-Z._]*\]");
            foreach (Match matche in matches)
            {
                var replaceTarget = matche.Value;
                var replaceId = replaceTarget.Replace("[", "").Replace("]", "");
                var replaceHelp = GetHelp(replaceId, false);
                if (replaceHelp == null)
                {
                    continue;
                }
                ret.description = ret.description.Replace(replaceTarget, replaceHelp.title);
                ret.relationIds.Add(replaceHelp.id);
            }
        }
        
        return ret;
    }

    /// <summary>
    /// シリアライズ前の処理
    /// </summary>
    public void OnBeforeSerialize()
    {
    }
    
    /// <summary>
    /// デシリアライズ後の処理
    /// </summary>
    public void OnAfterDeserialize()
    {
        // _helpMapを初期化する
        for (int i = 0; i < _helps.Count; i++){
            var help = _helps[i];
            if (_helpMap.ContainsKey(help.id)){
                _helpMap[help.id] = help;   
            }
            else {
                _helpMap.Add(help.id, help);
            }
        }
    }
    
    /// <summary>
    /// インスタンスを生成する
    /// </summary>
    [MenuItem("Balloon Help/Create Source")]
    public static void CreateInstance()
    {
        var instance = AssetDatabase.FindAssets("t:" + typeof(BalloonHelpSource).Name)
                 .Select(guid => AssetDatabase.LoadAssetAtPath<BalloonHelpSource>(AssetDatabase.GUIDToAssetPath(guid)))
                 .FirstOrDefault();
        if (instance != null){
            // すでに存在する場合は作らない
            Selection.activeObject = instance;
            EditorUtility.DisplayDialog("確認", "既に作成されています\n" + AssetDatabase.GetAssetPath(instance), "OK");
            return;
        }
        
        // ファイル保存パネルを表示して保存
        var path = "Assets";
        string ext = "asset";
        var fileName = "name." + ext;
        fileName = System.IO.Path.GetFileNameWithoutExtension(AssetDatabase.GenerateUniqueAssetPath(System.IO.Path.Combine(path, fileName)));
        path = EditorUtility.SaveFilePanelInProject("Save Balloon Help Source", fileName, ext, "", path);
        if (!string.IsNullOrEmpty(path)) {
            instance = CreateInstance<BalloonHelpSource>();
            AssetDatabase.CreateAsset(instance, path);
            AssetDatabase.Refresh();
        }
    }

    /// <summary>
    /// インスタンスを選択する
    /// </summary>
    [MenuItem("Balloon Help/Select Source")]
    public static void SelectInstance()
    {
        var instance = AssetDatabase.FindAssets("t:" + typeof(BalloonHelpSource).Name)
                 .Select(guid => AssetDatabase.LoadAssetAtPath<BalloonHelpSource>(AssetDatabase.GUIDToAssetPath(guid)))
                 .FirstOrDefault();
        if (instance != null){
            Selection.activeObject = instance;
            return;
        }
        else {
            EditorUtility.DisplayDialog("確認", "生成済みのSourceがありません。" + AssetDatabase.GetAssetPath(instance), "OK");
            return;
        }
    }
}
#endif

説明はコメントに書いてある通りです。

またViewにはPopupWindowContentを使っています。
この部分のソースコードを示します。

#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;

public class BalloonHelp : PopupWindowContent
{
    private const float WINDOW_WIDTH = 300.0f;
    private const float WINDOW_PADDING = 8.0f;
    private const string RELATION_TEXT = "Relations";

    private static GUIStyle _contentLabelStyle;
    private static GUIStyle _titleLabelStyle;
    private static GUIStyle _relationLabelStyle;
    
    private BalloonHelpSource.Help _help;
    private Vector2 _windowSize;

    public static void Show(string id, Vector2 position)
    {
        var rect = new Rect(position, Vector2.zero);
        var content = new BalloonHelp(id);
        PopupWindow.Show(rect, content);
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    private BalloonHelp(string id)
    {
        // Sourceが生成されていなかったらサイズだけ決める
        if (BalloonHelpSource.Instance == null) {
            _windowSize = new Vector2(WINDOW_WIDTH, 100);
            return;
        }

        // データソースを取得する
        _help = BalloonHelpSource.Instance.GetHelp(id);
        
        // Sourceが生成されていなかったらサイズだけ決める
        if (_help == null)
        {
            _windowSize = new Vector2(WINDOW_WIDTH, 100);
            return;
        }
        
        // Styleを定義する
        _contentLabelStyle = new GUIStyle(EditorStyles.label);
        _contentLabelStyle.wordWrap = true;
        _titleLabelStyle = new GUIStyle(EditorStyles.boldLabel);
        _titleLabelStyle.wordWrap = true;
        _relationLabelStyle = new GUIStyle(EditorStyles.label);
        _relationLabelStyle.wordWrap = true;
        _relationLabelStyle.normal.textColor = new Color(0.4f, 0.4f, 1.0f);

        // ウィンドウサイズを計算する
        var labelWidth = WINDOW_WIDTH - (WINDOW_PADDING * 2);
        _windowSize = Vector2.zero;
        _windowSize.x = WINDOW_WIDTH;
        _windowSize.y += _titleLabelStyle.CalcHeight(new GUIContent(_help.title), labelWidth);
        _windowSize.y += 4;
        _windowSize.y += _contentLabelStyle.CalcHeight(new GUIContent(_help.description), labelWidth);
        if (_help.relationIds.Count >= 1)
        {
            _windowSize.y += 4;
            _windowSize.y += 8; // EditorGUILayout.Space();
            _windowSize.y += 4;
            _windowSize.y += _contentLabelStyle.CalcHeight(new GUIContent(RELATION_TEXT), labelWidth);
            _windowSize.y += 4;
            for (int i = 0; i < _help.relationIds.Count; i++)
            {
                _windowSize.y += _relationLabelStyle.CalcHeight(new GUIContent(_help.relationIds[i]), labelWidth);
            }
        }
        _windowSize.y += WINDOW_PADDING * 2;
    }

    public override Vector2 GetWindowSize()
    {
        return _windowSize;
    }

    public override void OnGUI(Rect rect)
    {
        // 余白を定義する
        using (new EditorGUILayout.HorizontalScope())
        {
            GUILayout.Space(WINDOW_PADDING);
            using (new EditorGUILayout.VerticalScope())
            {
                GUILayout.Space(WINDOW_PADDING);
                if (BalloonHelpSource.Instance == null) {
                    // BalloonHelpSourceが無いエラーを表示する
                    DrawSourceNotFoundError();
                }
                else if (_help == null) {
                    // Helpが無いエラーを表示する
                    DrawHelpNotFoundError();
                }
                else {
                    // 内容を表示する
                    DrawContent();
                }
            }
        }
    }

    /// <summary>
    /// ウィンドウの中身を描画する
    /// </summary>
    private void DrawContent()
    {
        // 説明文を描画する
        EditorGUILayout.LabelField(_help.title, _titleLabelStyle);
        EditorGUILayout.LabelField(_help.description, _contentLabelStyle);

        // 関連リンクを描画する
        if (_help.relationIds.Count >= 1)
        {
            EditorGUILayout.Space();
            EditorGUILayout.LabelField(RELATION_TEXT, _titleLabelStyle);

            for (int i = 0; i < _help.relationIds.Count; i++)
            {
                var relationId = _help.relationIds[i];
                var relationSource = BalloonHelpSource.Instance.GetHelp(relationId);
                var relationTitle = relationSource == null ? relationId : relationSource.title;
                if (GUILayout.Button(relationTitle, _relationLabelStyle))
                {
                    var mouseRect = new Rect(Event.current.mousePosition, Vector2.one);
                    var content = new BalloonHelp(relationId);
                    PopupWindow.Show(mouseRect, content);
                }
            }
        }
    }
    
    /// <summary>
    /// Sourceが見つからないエラーを表示
    /// </summary>
    private void DrawSourceNotFoundError()
    {
        EditorGUILayout.LabelField("BalloonHelpSourceが作成されていません。");
        if (GUILayout.Button("作成する")) {
            BalloonHelpSource.CreateInstance();
        }
    }

    /// <summary>
    /// Helpが見つからないエラーを表示
    /// </summary>
    private void DrawHelpNotFoundError()
    {
        EditorGUILayout.LabelField("Helpが定義されていません。");
        if (GUILayout.Button("編集する")) {
            BalloonHelpSource.SelectInstance();
        }
    }
}
#endif

ここまででBalloonHelp.Show()でウィンドウが開かれるようになりました。

ソースコードアトリビュート

直接BalloonHelp.Show()で呼び出してもいいですが、アトリビュートで簡単に呼び出せるようにして見ます。

using UnityEngine;

public class BalloonHelpAttribute : PropertyAttribute {
    
    public string helpId;
    
    public BalloonHelpAttribute(string helpId)
    {
        this.helpId = helpId;
    }
}

PropertyAttributeにはデータソースのIDを保持しておきます。
次にこのアトリビュートのPropertyDrawerを書きます。

#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer(typeof(BalloonHelpAttribute))]
public class BalloonHelpAttributeDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        var attr = attribute as BalloonHelpAttribute;

        var style = new GUIStyle(EditorStyles.label);
        style.normal.textColor = new Color(0.4f, 0.4f, 1.0f);
        
        // ラベルを描画する
        var propertyRect = EditorGUI.PrefixLabel(position, label, style);
        position.xMax = propertyRect.xMin;
        if (position.Contains(Event.current.mousePosition) && Event.current.type == EventType.MouseUp && Event.current.button == 0)
        {
            BalloonHelp.Show(attr.helpId, Event.current.mousePosition);
        }
        
        // プロパティフィールドを描画する
        var preIndent = EditorGUI.indentLevel;
        EditorGUI.indentLevel = 0;
        EditorGUI.PropertyField(propertyRect, property, GUIContent.none, true);
        EditorGUI.indentLevel = preIndent;
    }
    
    public override float GetPropertyHeight (SerializedProperty property, GUIContent label)
    {
        return EditorGUI.GetPropertyHeight(property);
    }
}

#endif

基本的にそのまま描画していますが、ラベル部分の色を変えて、
押下したらBalloonHelp.Show()を呼ぶ処理を加えています。

結果

f:id:halya_11:20180809235917p:plain:w400