Files
zeling_v2/Assets/_Game/Scripts/Editor/Tools/SOManagerWindow.cs
Joywayer 82ce9ff09a refactor(editor): reorganize Editor directory and unify menu hierarchy
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>
2026-05-20 11:52:17 +08:00

394 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}";
}
}
}