File directory changes (mirror Scripts/ module structure): - AbilityTypeDrawer.cs → Equipment/ - CharacterWizardWindow.cs → Character/ - FormEditorWindow.cs → Player/ - GMToolWindow.cs → Tools/ - SOManagerWindow.cs → Tools/ - Map/MapRoomDataEditor.cs → World/Map/ - Navigation/ (root) → Enemies/Navigation/ - Achievements/ → Progression/ Menu hierarchy changes (BaseGames/ top-level): - Data/: +Character Wizard (from Tools/), +Boss Skill Sequence (from Tools/) - Addressables/: +Addressable Batch Tool, +Asset Reference Graph, +Validate Address Keys (from Tools/Verification/) - Scene/Setup/: +Boot Flow Wizard, +Scaffold *, +Auto-Open Persistent (from Tools/) - Scene/: +Camera Area Setup (from Camera/), +Bake All NavSurfaces (from Tools/) - Events/: +Event Bus Monitor, +Event Chain Viewer, +Create/Reimport Event Channels (from Tools/) - Tools/Validation/: +Validate All SOs, +Apply/Validate Script Order (from Tools/ flat) - Tools/Maintenance/: +Missing Scripts/*, +Physics2D Layer Matrix/* (from Tools/ flat) Result: BaseGames/Tools/ reduced from 16 flat items to 4 items + 2 submenus Docs: update AssetFolderSpec §12 editor tool table with new menu paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
394 lines
17 KiB
C#
394 lines
17 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using UnityEditor;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
|
||
namespace BaseGames.Editor
|
||
{
|
||
/// <summary>
|
||
/// SO 资产总管理窗口 —— 浏览、搜索并在 Project 窗口定位项目中所有 ScriptableObject 资产。
|
||
///
|
||
/// 布局:顶部搜索栏 | 左侧分类列表 | 右侧资产列表(名称 / 类型 / 路径)
|
||
/// 功能:单击资产行 → Project 窗口 Ping 并选中;双击 → 同上并聚焦 Project 窗口。
|
||
/// 菜单:BaseGames / Tools / SO Manager (Priority 2)
|
||
/// </summary>
|
||
public class SOManagerWindow : EditorWindow
|
||
{
|
||
private const string DataRoot = "Assets/_Game/Data";
|
||
private const string UssPath = "Assets/_Game/Scripts/Editor/UIToolkit/Editor.uss";
|
||
|
||
// ── 数据模型 ──────────────────────────────────────────────────────────
|
||
|
||
private sealed class CategoryEntry
|
||
{
|
||
public string Label;
|
||
public string Folder; // null = 全部
|
||
public int Count;
|
||
}
|
||
|
||
private sealed class AssetEntry
|
||
{
|
||
public string Name;
|
||
public string TypeName;
|
||
public string AssetPath;
|
||
public ScriptableObject Asset;
|
||
}
|
||
|
||
private readonly List<CategoryEntry> _categories = new();
|
||
private readonly List<AssetEntry> _allAssets = new();
|
||
private readonly List<AssetEntry> _filtered = new();
|
||
|
||
private int _selectedCatIdx = 0;
|
||
private string _search = "";
|
||
|
||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||
|
||
private ListView _catList;
|
||
private ListView _assetList;
|
||
private TextField _searchField;
|
||
private Label _statusLabel;
|
||
|
||
// ── 菜单入口 ──────────────────────────────────────────────────────────
|
||
|
||
[MenuItem("BaseGames/Tools/SO Manager", priority = 2)]
|
||
public static void Open()
|
||
{
|
||
var wnd = GetWindow<SOManagerWindow>();
|
||
wnd.titleContent = new GUIContent("SO Manager",
|
||
EditorGUIUtility.IconContent("d_ScriptableObject Icon").image);
|
||
wnd.minSize = new Vector2(680, 420);
|
||
}
|
||
|
||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||
|
||
public void CreateGUI()
|
||
{
|
||
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||
if (uss != null) rootVisualElement.styleSheets.Add(uss);
|
||
|
||
BuildUI();
|
||
Refresh();
|
||
}
|
||
|
||
private void OnFocus() => Refresh();
|
||
|
||
// ── UI 构建 ───────────────────────────────────────────────────────────
|
||
|
||
private void BuildUI()
|
||
{
|
||
rootVisualElement.style.flexDirection = FlexDirection.Column;
|
||
|
||
// ─ 顶部工具栏 ──────────────────────────────────────────────────────
|
||
var toolbar = new VisualElement();
|
||
toolbar.style.flexDirection = FlexDirection.Row;
|
||
toolbar.style.paddingLeft = 8;
|
||
toolbar.style.paddingRight = 8;
|
||
toolbar.style.paddingTop = 5;
|
||
toolbar.style.paddingBottom = 5;
|
||
toolbar.style.borderBottomWidth = 1;
|
||
toolbar.style.borderBottomColor = new Color(0.15f, 0.15f, 0.15f);
|
||
toolbar.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.6f);
|
||
|
||
var searchLbl = new Label("搜索:");
|
||
searchLbl.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||
searchLbl.style.marginRight = 4;
|
||
|
||
_searchField = new TextField();
|
||
_searchField.style.flexGrow = 1;
|
||
_searchField.RegisterValueChangedCallback(e =>
|
||
{
|
||
_search = e.newValue;
|
||
ApplyFilter();
|
||
});
|
||
|
||
var refreshBtn = new Button(Refresh) { text = "↻ 刷新" };
|
||
refreshBtn.style.marginLeft = 8;
|
||
refreshBtn.style.width = 58;
|
||
|
||
toolbar.Add(searchLbl);
|
||
toolbar.Add(_searchField);
|
||
toolbar.Add(refreshBtn);
|
||
rootVisualElement.Add(toolbar);
|
||
|
||
// ─ 主体:两栏分割 ──────────────────────────────────────────────────
|
||
var split = new TwoPaneSplitView(0, 164, TwoPaneSplitViewOrientation.Horizontal);
|
||
split.style.flexGrow = 1;
|
||
|
||
// 左栏:分类列表 ────────────────────────────────────────────────────
|
||
var leftPane = new VisualElement();
|
||
leftPane.style.flexDirection = FlexDirection.Column;
|
||
leftPane.style.minWidth = 100;
|
||
|
||
var catHeader = new Label("分类");
|
||
catHeader.style.paddingLeft = 8;
|
||
catHeader.style.paddingTop = 5;
|
||
catHeader.style.paddingBottom = 5;
|
||
catHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
catHeader.style.borderBottomWidth = 1;
|
||
catHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
|
||
catHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
|
||
leftPane.Add(catHeader);
|
||
|
||
_catList = new ListView
|
||
{
|
||
makeItem = MakeCatItem,
|
||
bindItem = BindCatItem,
|
||
selectionType = SelectionType.Single,
|
||
fixedItemHeight = 26,
|
||
};
|
||
_catList.style.flexGrow = 1;
|
||
_catList.selectionChanged += _ =>
|
||
{
|
||
if (_catList.selectedIndex >= 0)
|
||
{
|
||
_selectedCatIdx = _catList.selectedIndex;
|
||
ApplyFilter();
|
||
}
|
||
};
|
||
leftPane.Add(_catList);
|
||
|
||
// 右栏:资产列表 ────────────────────────────────────────────────────
|
||
var rightPane = new VisualElement();
|
||
rightPane.style.flexDirection = FlexDirection.Column;
|
||
|
||
// 列标题行
|
||
var colHeader = new VisualElement();
|
||
colHeader.style.flexDirection = FlexDirection.Row;
|
||
colHeader.style.paddingLeft = 8;
|
||
colHeader.style.paddingRight = 8;
|
||
colHeader.style.paddingTop = 4;
|
||
colHeader.style.paddingBottom = 4;
|
||
colHeader.style.borderBottomWidth = 1;
|
||
colHeader.style.borderBottomColor = new Color(0.22f, 0.22f, 0.22f);
|
||
colHeader.style.backgroundColor = new Color(0.22f, 0.22f, 0.22f, 0.4f);
|
||
colHeader.Add(MakeHeaderLabel("资产名", true, 0));
|
||
colHeader.Add(MakeHeaderLabel("类型", false, 170));
|
||
colHeader.Add(MakeHeaderLabel("路径", true, 0));
|
||
rightPane.Add(colHeader);
|
||
|
||
_assetList = new ListView
|
||
{
|
||
makeItem = MakeAssetRow,
|
||
bindItem = BindAssetRow,
|
||
selectionType = SelectionType.Single,
|
||
fixedItemHeight = 22,
|
||
};
|
||
_assetList.style.flexGrow = 1;
|
||
_assetList.selectionChanged += _ => OnAssetPicked();
|
||
_assetList.itemsChosen += _ => FocusProjectWindow();
|
||
rightPane.Add(_assetList);
|
||
|
||
// 状态栏
|
||
_statusLabel = new Label("—");
|
||
_statusLabel.style.paddingLeft = 8;
|
||
_statusLabel.style.paddingTop = 3;
|
||
_statusLabel.style.paddingBottom = 3;
|
||
_statusLabel.style.borderTopWidth = 1;
|
||
_statusLabel.style.borderTopColor = new Color(0.15f, 0.15f, 0.15f);
|
||
_statusLabel.style.color = new Color(0.58f, 0.58f, 0.58f);
|
||
_statusLabel.style.fontSize = 11;
|
||
rightPane.Add(_statusLabel);
|
||
|
||
split.Add(leftPane);
|
||
split.Add(rightPane);
|
||
rootVisualElement.Add(split);
|
||
}
|
||
|
||
private static Label MakeHeaderLabel(string text, bool grow, int fixedWidth)
|
||
{
|
||
var lbl = new Label(text);
|
||
lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||
lbl.style.overflow = Overflow.Hidden;
|
||
if (grow) lbl.style.flexGrow = 1;
|
||
if (fixedWidth > 0) lbl.style.width = fixedWidth;
|
||
return lbl;
|
||
}
|
||
|
||
// ── 分类列表项 ────────────────────────────────────────────────────────
|
||
|
||
private static VisualElement MakeCatItem()
|
||
{
|
||
var lbl = new Label();
|
||
lbl.style.paddingLeft = 10;
|
||
lbl.style.paddingRight = 6;
|
||
lbl.style.overflow = Overflow.Hidden;
|
||
lbl.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||
return lbl;
|
||
}
|
||
|
||
private void BindCatItem(VisualElement el, int i)
|
||
{
|
||
if (i >= _categories.Count) return;
|
||
var cat = _categories[i];
|
||
((Label)el).text = $"{cat.Label} ({cat.Count})";
|
||
}
|
||
|
||
// ── 资产列表项 ────────────────────────────────────────────────────────
|
||
|
||
private static VisualElement MakeAssetRow()
|
||
{
|
||
var row = new VisualElement();
|
||
row.style.flexDirection = FlexDirection.Row;
|
||
row.style.alignItems = Align.Center;
|
||
row.style.paddingLeft = 8;
|
||
row.style.paddingRight = 8;
|
||
|
||
var nameEl = new Label { name = "n" };
|
||
nameEl.style.flexGrow = 1;
|
||
nameEl.style.overflow = Overflow.Hidden;
|
||
nameEl.style.textOverflow = TextOverflow.Ellipsis;
|
||
|
||
var typeEl = new Label { name = "t" };
|
||
typeEl.style.width = 170;
|
||
typeEl.style.overflow = Overflow.Hidden;
|
||
typeEl.style.textOverflow = TextOverflow.Ellipsis;
|
||
typeEl.style.color = new Color(0.52f, 0.80f, 1.00f);
|
||
typeEl.style.fontSize = 11;
|
||
|
||
var pathEl = new Label { name = "p" };
|
||
pathEl.style.flexGrow = 1;
|
||
pathEl.style.overflow = Overflow.Hidden;
|
||
pathEl.style.textOverflow = TextOverflow.Ellipsis;
|
||
pathEl.style.color = new Color(0.48f, 0.48f, 0.48f);
|
||
pathEl.style.fontSize = 10;
|
||
|
||
row.Add(nameEl);
|
||
row.Add(typeEl);
|
||
row.Add(pathEl);
|
||
return row;
|
||
}
|
||
|
||
private void BindAssetRow(VisualElement el, int i)
|
||
{
|
||
if (i >= _filtered.Count) return;
|
||
var e = _filtered[i];
|
||
el.Q<Label>("n").text = e.Name;
|
||
el.Q<Label>("t").text = e.TypeName;
|
||
// 显示相对于 DataRoot 的路径,去掉文件名本身只保留目录
|
||
string rel = e.AssetPath.StartsWith(DataRoot + "/")
|
||
? e.AssetPath.Substring(DataRoot.Length + 1)
|
||
: e.AssetPath;
|
||
// 去掉最后的文件名,只显示目录部分
|
||
string dir = Path.GetDirectoryName(rel)?.Replace('\\', '/') ?? "";
|
||
el.Q<Label>("p").text = string.IsNullOrEmpty(dir) ? "/" : dir;
|
||
}
|
||
|
||
// ── 资产选中 ──────────────────────────────────────────────────────────
|
||
|
||
private void OnAssetPicked()
|
||
{
|
||
int idx = _assetList.selectedIndex;
|
||
if (idx < 0 || idx >= _filtered.Count) return;
|
||
var asset = _filtered[idx].Asset;
|
||
if (asset == null) return;
|
||
EditorGUIUtility.PingObject(asset);
|
||
Selection.activeObject = asset;
|
||
}
|
||
|
||
private static void FocusProjectWindow()
|
||
{
|
||
EditorApplication.ExecuteMenuItem("Window/General/Project");
|
||
}
|
||
|
||
// ── 数据逻辑 ──────────────────────────────────────────────────────────
|
||
|
||
private void Refresh()
|
||
{
|
||
_allAssets.Clear();
|
||
|
||
// 扫描 DataRoot 下所有 ScriptableObject 资产
|
||
var guids = AssetDatabase.FindAssets("t:ScriptableObject", new[] { DataRoot });
|
||
foreach (var guid in guids)
|
||
{
|
||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||
var asset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(path);
|
||
if (asset == null) continue;
|
||
_allAssets.Add(new AssetEntry
|
||
{
|
||
Name = asset.name,
|
||
TypeName = asset.GetType().Name,
|
||
AssetPath = path,
|
||
Asset = asset,
|
||
});
|
||
}
|
||
|
||
// 构建分类:首项为"全部",其余为 DataRoot 的直接子目录
|
||
_categories.Clear();
|
||
_categories.Add(new CategoryEntry
|
||
{
|
||
Label = "全部",
|
||
Folder = null,
|
||
Count = _allAssets.Count,
|
||
});
|
||
|
||
if (AssetDatabase.IsValidFolder(DataRoot))
|
||
{
|
||
foreach (var sub in AssetDatabase.GetSubFolders(DataRoot).OrderBy(f => f))
|
||
{
|
||
string folderName = Path.GetFileName(sub);
|
||
int count = _allAssets.Count(a =>
|
||
a.AssetPath.StartsWith(sub + "/", StringComparison.Ordinal));
|
||
if (count == 0) continue;
|
||
_categories.Add(new CategoryEntry
|
||
{
|
||
Label = folderName,
|
||
Folder = sub,
|
||
Count = count,
|
||
});
|
||
}
|
||
}
|
||
|
||
_catList.itemsSource = _categories;
|
||
_catList.Rebuild();
|
||
|
||
int clampedIdx = Mathf.Clamp(_selectedCatIdx, 0, _categories.Count - 1);
|
||
_catList.SetSelection(clampedIdx);
|
||
_selectedCatIdx = clampedIdx;
|
||
ApplyFilter();
|
||
}
|
||
|
||
private void ApplyFilter()
|
||
{
|
||
_filtered.Clear();
|
||
|
||
string folder = (_selectedCatIdx >= 0 && _selectedCatIdx < _categories.Count)
|
||
? _categories[_selectedCatIdx].Folder
|
||
: null;
|
||
|
||
IEnumerable<AssetEntry> source = folder == null
|
||
? _allAssets
|
||
: _allAssets.Where(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
|
||
|
||
foreach (var entry in source)
|
||
{
|
||
if (string.IsNullOrEmpty(_search)
|
||
|| entry.Name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0
|
||
|| entry.TypeName.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
{
|
||
_filtered.Add(entry);
|
||
}
|
||
}
|
||
|
||
// 同分类内按类型分组,再按名称排序
|
||
_filtered.Sort((a, b) =>
|
||
{
|
||
int cmp = string.Compare(a.TypeName, b.TypeName, StringComparison.OrdinalIgnoreCase);
|
||
return cmp != 0 ? cmp : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
||
});
|
||
|
||
_assetList.itemsSource = _filtered;
|
||
_assetList.Rebuild();
|
||
|
||
int total = folder == null
|
||
? _allAssets.Count
|
||
: _allAssets.Count(a => a.AssetPath.StartsWith(folder + "/", StringComparison.Ordinal));
|
||
_statusLabel.text = string.IsNullOrEmpty(_search)
|
||
? $"共 {_filtered.Count} 个资产"
|
||
: $"筛选 {_filtered.Count} / {total} 个资产(搜索:{_search})";
|
||
}
|
||
}
|
||
}
|