ScriptableObject管理工具

需求

Unity原生的Inspector对ScriptableObject的支持不是太好,如果把SO命名为数字Id,或是为SO配置Image,都难以预览,更好的方法是为SO专门重写一个Inspector,但太过复杂,于是参考RPG Maker Unite的数据库菜单,使用UI Toolkit写一个小窗口,将要管理的SO列表化,预览内容,点击定位到对应SO文件。

思路

参考RPG Maker Unite的数据库样式:

思路如图:

要管理的SO的属性:Id, Name, Icon, Catalog, 其中Catalog有三个枚举值:Item, Weapon, Armor:

折叠项显示:配置的Catalog,SO根据Catalog分类;

菜单项显示:SO的数字Id和配置的Name;点击事件为点击预览和点击定位SO文件;

CurrentActiveItem显示:SO配置的Image和Name、SO的路径;

接下来明确SO集合Items的数据结构:Dictionary<string, ScriptableObject>,其中,key为SO的guid,方便点击按钮定位对应文件。

解决方案

首先创建一个EditorWindow,里面有一个View,还要为View创建对应的uss文件来绘制元素样式。

源码参考RPG Maker Unite。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class DataHierarchyWindow : EditorWindow
{
private DataHierarchyView m_View;

public DataHierarchyView View
{
get => m_View;
set
{
m_View = value;
if (m_View != null)
{
rootVisualElement.Add(m_View);
}
}
}

private void Awake()
{
View = new();
}

[MenuItem("DataHierarchy/DataHierarchy")]
public static void OpenGraphWindow()
{
DataHierarchyWindow window = GetWindow<DataHierarchyWindow>();
window.titleContent = new("DataHierarchy");
window.minSize = new Vector2(318, 578);
}

private void OnDestroy()
{
m_View.Dispose();
}
}

在View里先绘制一个ScrollView:

1
2
3
4
5
6
7
8
9
10
11
public class DataHierarchyView : VisualElement, IDisposable
{
private readonly ScrollView m_scrollView;

public DataHierarchyView()
{
m_scrollView = new ScrollView(ScrollViewMode.Vertical);
Add(m_scrollView);
RefreshCatalogueContent();
}
}

再在View里绘制预览区域,它与ScrollView同层级:

1
2
3
4
5
6
7
8
9
private readonly VisualElement m_PreviewContainer;

{
m_PreviewContainer = new()
{
name = "preview"
};
Add(m_PreviewContainer);
}

根据思路,预览区域存在一个Icon和一个Container,Container里存在两个Label:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private readonly Image m_Icon;
private readonly VisualElement m_TextContainer;
private readonly Label m_TitleLabel;
private readonly Label m_PathLabel;

{
m_PreviewContainer.Add(m_Icon);
m_PreviewContainer.Add(m_TextContainer);
m_TitleLabel = new()
{
name = "title",
};
m_PathLabel = new()
{
name = "path",
};

m_TextContainer.Add(m_TitleLabel);
m_TextContainer.Add(m_PathLabel);
}

假设Catalog有三个类型:Item, Weapon, Armor,对应ItemSO, WeaponSO, ArmorSO;

前面提到SO集合Items的数据结构是Dictionary<string, ScriptableObject>,

那么ScrollView的内容的数据类型为Dictionary<ItemCatalog, List<string>>;其中,value为guid的集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void RefreshCatalogueContent()
{
m_scrollView.Clear();
CreateCatalogueContent();
}

private void CreateCatalogueContent()
{
Dictionary<ItemCatalog, List<string>> dic = Items
.OrderBy(x => x.Value.Id)
.GroupBy(x => x.Value.Catalog)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.Key).ToList()
);
}

遍历字典,创建UIElement:根据Catalog创建Foldout,根据SO配置的Catalog,创建Button,创建在相应的Foldout下;通过将guid赋值给Button的viewDataKey,在之后实现点击定位到对应SO文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
foreach (var kvp in dic)
{
Foldout foldout = new()
{
text = kvp.Key.ToString()
};
foreach (var v in kvp.Value)
{
Button button = new()
{
text = Items[v].Id + ". " + Items[v].Name,
viewDataKey = v,
};
button.clickable.clicked += () => { SetActiveItem(button); };
button.AddToClassList("selectableElement");
foldout.Add(button);
}
m_scrollView.contentContainer.Add(foldout);
}
}

为了实现点击预览功能,需要将点击的Button设置为currentActiveItem,通过Button的viewDataKey,在Items中查找currentActiveItem的SO对象,获取属性,在预览区域显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static VisualElement currentActiveItem;

private void SetActiveItem(VisualElement item)
{
DeactivateAllItems();
currentActiveItem = item;
currentActiveItem.AddToClassList("active");
SelectAssetInProject();
UpdateItemPreview();
}

public void DeactivateAllItems()
{
this.Query<Foldout>().ForEach(f => { f.RemoveFromClassList("active"); });
this.Query<Button>().ForEach(f => { f.RemoveFromClassList("active"); });
this.Query<Label>().ForEach(f => { f.RemoveFromClassList("active"); });
}

private void UpdateItemPreview()
{
string key = currentActiveItem.viewDataKey;
m_TitleLabel.text = Items[key].Name;
m_Icon.sprite = Items[key].Icon;
string path = AssetDatabase.GUIDToAssetPath(currentActiveItem.viewDataKey);
m_PathLabel.text = path;
}

为了美观,可以配置uss样式,为Button的元素类hover和active配置悬浮态和选中态颜色:

1
2
3
4
5
6
7
.unity-button:hover {
background-color: rgba(48, 48, 48, 1);
}

.unity-button.active {
background-color: rgba(44, 93, 135, 1);
}

同样,也可以为preview区域配置对应的uss样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
DataHierarchyView 
{
height: 100%;
width : 100%;
}

DataHierarchyView > .unity-scroll-view
{
flex-grow: 1;
}

DataHierarchyView > #preview
{
align-self: stretch;
height: 100px;
background-color: rgba(49,49,49,1);

justify-content: flex-start;
align-items: stretch;
flex-direction: row;
padding: 16px;
}

DataHierarchyView > #preview > #icon
{
width : 64px;
height: 64px;
background-color : #E1E1E1;
}

DataHierarchyView > #preview > #container
{
flex: 1 1 auto;
margin-left: 16px;
flex-direction: column;
}

将uss样式添加到View类的方法是:

1
2
3
4
public DataHierarchyView()
{
styleSheets.Add(Resources.Load<StyleSheet>("Styles/DataHierarchyView"));
}

最后再做一下点击按钮,在Project面板中定位到对应SO文件的功能:

1
2
3
4
5
6
7
private void SelectAssetInProject()
{
string path = AssetDatabase.GUIDToAssetPath(currentActiveItem.viewDataKey);
UnityEngine.Object obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
EditorGUIUtility.PingObject(obj);
Selection.activeObject = obj;
}

到这里基本就完成了,但还有一个问题:打开管理菜单的时候,如果变更了SO的配置,要怎么更新?其实只需要再创建一个Action,订阅事件,在SO类的OnValidate方法回调就好。

1
2
3
4
{   
SO.OnSOModified += RefreshCatalogueContent;
SO.OnSOModified += UpdateItemPreview;
}

需要注意的是,OnValidate的定义是加载脚本后或检视面板中的值发生更改时,将调用此函数,为了只在值发生更改时回调,可以在OnValidate方法中加一个判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SO : ScriptableObject
{
public static Action OnSOModified;

private bool firstOnValidateHasOccurred;

private void OnValidate()
{
if (!firstOnValidateHasOccurred)
{
firstOnValidateHasOccurred = true;
return;
}
OnSOModified?.Invoke();
}
}

除此之外,管理工具还要考虑几个问题:如何把所有SO添加到Items中;管理菜单打开状态下,创建和删除了SO,如何刷新列表。暂时不一一赘述了。

最终效果如图:

文档链接

UI Toolkit

FirstOnValidateHasOccurred