feat: Add SkillModule and WeaponModule for managing skills and weapons
- Implemented SkillModule to manage FormSkillSO assets with a detailed UI for editing and displaying skill properties. - Implemented WeaponModule to manage WeaponSO assets with a detailed UI for editing and displaying weapon properties. - Created AssetOperations class for centralized CRUD operations on ScriptableObject assets, including create, rename, delete, and clone functionalities. - Added DetailHeader for displaying and renaming asset names in the UI. - Introduced SoListPane for a reusable ScriptableObject list panel with search functionality and context menus. - Added meta files for all new scripts to ensure proper asset management in Unity.
This commit is contained in:
158
Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs
Normal file
158
Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 集中管理 ScriptableObject 资产的 CRUD 操作(含 Undo 支持)。
|
||||
/// </summary>
|
||||
public static class AssetOperations
|
||||
{
|
||||
// ── 创建 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 弹出 SaveFilePanel 让用户选择路径,创建并返回新资产。
|
||||
/// 创建失败或用户取消时返回 null。
|
||||
/// </summary>
|
||||
public static T Create<T>(string defaultFolder, string defaultName) where T : ScriptableObject
|
||||
{
|
||||
if (!Directory.Exists(defaultFolder))
|
||||
Directory.CreateDirectory(defaultFolder);
|
||||
|
||||
string path = EditorUtility.SaveFilePanelInProject(
|
||||
"新建 " + typeof(T).Name,
|
||||
defaultName + ".asset",
|
||||
"asset",
|
||||
"选择保存路径",
|
||||
defaultFolder);
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return null;
|
||||
|
||||
var asset = ScriptableObject.CreateInstance<T>();
|
||||
asset.name = Path.GetFileNameWithoutExtension(path);
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
Undo.RegisterCreatedObjectUndo(asset, "Create " + typeof(T).Name);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
return asset;
|
||||
}
|
||||
|
||||
// ── 重命名 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 重命名资产(同时更新磁盘文件名和 asset.name)。
|
||||
/// 返回 (true, null) 成功;(false, errorMsg) 失败。
|
||||
/// </summary>
|
||||
public static (bool ok, string error) Rename(UnityEngine.Object asset, string newName)
|
||||
{
|
||||
if (asset == null) return (false, "资产为 null");
|
||||
if (string.IsNullOrWhiteSpace(newName)) return (false, "名称不能为空");
|
||||
|
||||
newName = newName.Trim();
|
||||
string path = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(path)) return (false, "资产不在 AssetDatabase 中");
|
||||
|
||||
// 先更新序列化内部名称
|
||||
string oldName = asset.name;
|
||||
Undo.RecordObject(asset, "Rename " + oldName);
|
||||
asset.name = newName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
|
||||
// 再重命名磁盘文件
|
||||
string err = AssetDatabase.RenameAsset(path, newName);
|
||||
if (!string.IsNullOrEmpty(err))
|
||||
{
|
||||
asset.name = oldName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
return (false, err);
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ── 删除 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>弹出确认对话框,确认后删除资产文件。返回是否已删除。</summary>
|
||||
public static bool Delete(UnityEngine.Object asset)
|
||||
{
|
||||
if (asset == null) return false;
|
||||
string path = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
|
||||
if (!EditorUtility.DisplayDialog(
|
||||
"确认删除",
|
||||
$"删除资产:{asset.name}\n路径:{path}\n\n此操作不可撤销。",
|
||||
"删除", "取消"))
|
||||
return false;
|
||||
|
||||
AssetDatabase.DeleteAsset(path);
|
||||
AssetDatabase.Refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 克隆 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>复制资产文件并返回克隆的资产;用户取消或失败时返回 null。</summary>
|
||||
public static T Clone<T>(T source, string defaultFolder) where T : ScriptableObject
|
||||
{
|
||||
if (source == null) return null;
|
||||
string srcPath = AssetDatabase.GetAssetPath(source);
|
||||
|
||||
string path = EditorUtility.SaveFilePanelInProject(
|
||||
"克隆 " + source.name,
|
||||
source.name + "_Copy.asset",
|
||||
"asset",
|
||||
"选择保存路径",
|
||||
defaultFolder);
|
||||
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
|
||||
if (!AssetDatabase.CopyAsset(srcPath, path))
|
||||
{
|
||||
Debug.LogError($"[AssetOperations] 克隆失败:{srcPath} → {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
var clone = AssetDatabase.LoadAssetAtPath<T>(path);
|
||||
if (clone != null)
|
||||
Undo.RegisterCreatedObjectUndo(clone, "Clone " + source.name);
|
||||
return clone;
|
||||
}
|
||||
|
||||
// ── 查询 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>在 AssetDatabase 中查找所有 T 类型资产。</summary>
|
||||
public static List<T> FindAll<T>() where T : ScriptableObject
|
||||
{
|
||||
var result = new List<T>();
|
||||
string[] guids = AssetDatabase.FindAssets("t:" + typeof(T).Name);
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string p = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath<T>(p);
|
||||
if (asset != null) result.Add(asset);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── GUID 工具 ─────────────────────────────────────────────────────────
|
||||
|
||||
public static string GetGuid(UnityEngine.Object asset)
|
||||
{
|
||||
if (asset == null) return string.Empty;
|
||||
return AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(asset));
|
||||
}
|
||||
|
||||
public static T LoadByGuid<T>(string guid) where T : UnityEngine.Object
|
||||
{
|
||||
if (string.IsNullOrEmpty(guid)) return null;
|
||||
return AssetDatabase.LoadAssetAtPath<T>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Shared/AssetOperations.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2212f3dc47d61dd42b245a2470d2a90a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
123
Assets/_Game/Scripts/Editor/Shared/DetailHeader.cs
Normal file
123
Assets/_Game/Scripts/Editor/Shared/DetailHeader.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 详情区顶部标题行:显示资产名称,双击或按 ✏ 进入行内重命名。
|
||||
/// </summary>
|
||||
public class DetailHeader : VisualElement
|
||||
{
|
||||
// ── 事件 ─────────────────────────────────────────────────────────────
|
||||
/// <summary>用户确认重命名时触发,参数为新名称字符串。</summary>
|
||||
public event Action<string> RenameRequested;
|
||||
|
||||
// ── 私有字段 ──────────────────────────────────────────────────────────
|
||||
private Label _nameLabel;
|
||||
private TextField _renameField;
|
||||
private bool _renaming;
|
||||
|
||||
// ── 构造 ─────────────────────────────────────────────────────────────
|
||||
|
||||
public DetailHeader()
|
||||
{
|
||||
style.flexDirection = FlexDirection.Row;
|
||||
style.alignItems = Align.Center;
|
||||
style.paddingLeft = 12;
|
||||
style.paddingRight = 8;
|
||||
style.paddingTop = 10;
|
||||
style.paddingBottom = 10;
|
||||
style.borderBottomWidth = 1;
|
||||
style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.2f));
|
||||
|
||||
// 名称 Label
|
||||
_nameLabel = new Label("(未选中)");
|
||||
_nameLabel.style.flexGrow = 1;
|
||||
_nameLabel.style.fontSize = 15;
|
||||
_nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
_nameLabel.RegisterCallback<MouseDownEvent>(e =>
|
||||
{
|
||||
if (e.clickCount == 2) BeginRename();
|
||||
});
|
||||
Add(_nameLabel);
|
||||
|
||||
// 重命名输入框(默认隐藏)
|
||||
_renameField = new TextField();
|
||||
_renameField.style.flexGrow = 1;
|
||||
_renameField.style.fontSize = 15;
|
||||
_renameField.style.display = DisplayStyle.None;
|
||||
_renameField.RegisterCallback<KeyDownEvent>(e =>
|
||||
{
|
||||
if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
|
||||
{
|
||||
e.StopPropagation();
|
||||
CommitRename();
|
||||
}
|
||||
else if (e.keyCode == KeyCode.Escape)
|
||||
{
|
||||
e.StopPropagation();
|
||||
CancelRename();
|
||||
}
|
||||
});
|
||||
_renameField.RegisterCallback<FocusOutEvent>(_ => CommitRename());
|
||||
Add(_renameField);
|
||||
|
||||
// 编辑按钮
|
||||
var btnEdit = new Button(BeginRename) { text = "✏", tooltip = "重命名(双击名称也可触发)" };
|
||||
btnEdit.style.width = 24;
|
||||
btnEdit.style.height = 24;
|
||||
btnEdit.style.marginLeft = 6;
|
||||
btnEdit.style.paddingLeft = 0;
|
||||
btnEdit.style.paddingRight = 0;
|
||||
btnEdit.style.fontSize = 13;
|
||||
Add(btnEdit);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>绑定新资产,更新标题显示。</summary>
|
||||
public void SetAsset(UnityEngine.Object asset)
|
||||
{
|
||||
CancelRename();
|
||||
_nameLabel.text = asset != null ? asset.name : "(未选中)";
|
||||
}
|
||||
|
||||
// ── 内部逻辑 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void BeginRename()
|
||||
{
|
||||
if (_renaming) return;
|
||||
_renaming = true;
|
||||
_renameField.value = _nameLabel.text;
|
||||
_nameLabel.style.display = DisplayStyle.None;
|
||||
_renameField.style.display = DisplayStyle.Flex;
|
||||
schedule.Execute(() =>
|
||||
{
|
||||
_renameField.Focus();
|
||||
_renameField.SelectAll();
|
||||
});
|
||||
}
|
||||
|
||||
private void CommitRename()
|
||||
{
|
||||
if (!_renaming) return;
|
||||
_renaming = false;
|
||||
_nameLabel.style.display = DisplayStyle.Flex;
|
||||
_renameField.style.display = DisplayStyle.None;
|
||||
|
||||
var newName = _renameField.value.Trim();
|
||||
if (!string.IsNullOrEmpty(newName) && newName != _nameLabel.text)
|
||||
RenameRequested?.Invoke(newName);
|
||||
}
|
||||
|
||||
private void CancelRename()
|
||||
{
|
||||
if (!_renaming) return;
|
||||
_renaming = false;
|
||||
_nameLabel.style.display = DisplayStyle.Flex;
|
||||
_renameField.style.display = DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Shared/DetailHeader.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Shared/DetailHeader.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4a51b9cef4da264fb261dac2e74700e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
@@ -11,6 +12,139 @@ namespace BaseGames.Editor
|
||||
/// </summary>
|
||||
public static class EditorScaffoldUtils
|
||||
{
|
||||
// ── 资产命名前缀 ──────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Dictionary<string, (string Prefix, string Convention)> s_PrefixMap = new()
|
||||
{
|
||||
{ "WeaponSO", ("WPN_", "WPN_{ID},例:WPN_SkyBlade") },
|
||||
{ "FormSkillSO", ("SKL_", "SKL_{Name},例:SKL_SoulBlade") },
|
||||
{ "BossSkillSO", ("SKL_", "SKL_{Name},例:SKL_BossRage") },
|
||||
{ "SkillSequenceSO", ("SKL_", "SKL_Seq_{Name},例:SKL_Seq_RageCombo") },
|
||||
{ "EnemyStatsSO", ("ENM_", "ENM_E{ID}_Stats,例:ENM_E001_Stats") },
|
||||
{ "LootTableSO", ("ENM_", "ENM_E{ID}_Loot,例:ENM_E001_Loot") },
|
||||
{ "FormConfigSO", ("PLY_", "PLY_{FormID},例:PLY_Player01") },
|
||||
{ "DamageSourceSO", ("CMB_", "CMB_DamageSource_{Name},例:CMB_DamageSource_Sword") },
|
||||
{ "AbilityConfigSO", ("ABL_", "ABL_{Name},例:ABL_DoubleJump") },
|
||||
{ "CharmConfigSO", ("CHM_", "CHM_{Name},例:CHM_GhostMantis") },
|
||||
{ "ShopInventorySO", ("SHP_", "SHP_Inventory_{Name},例:SHP_Inventory_Forest") },
|
||||
{ "MapRoomDataSO", ("MAP_", "MAP_RoomData_{Name},例:MAP_RoomData_Forest_01") },
|
||||
{ "AudioPlaylistSO", ("AUD_", "AUD_BGM_{Name},例:AUD_BGM_Forest") },
|
||||
{ "AudioConfigSO", ("AUD_", "AUD_SFX_{Name},例:AUD_SFX_Sword") },
|
||||
{ "GlobalSettingsSO", ("SET_", "SET_{Name},例:SET_GlobalSettings") },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 根据 SO 类型返回命名前缀和规范说明。
|
||||
/// 事件频道(类名以 EventChannelSO 结尾)统一返回 EVT_ 前缀。
|
||||
/// </summary>
|
||||
public static (string Prefix, string Convention) GetAssetPrefixInfo(Type t)
|
||||
{
|
||||
if (t == null) return ("", "");
|
||||
// 事件频道
|
||||
if (t.Name.EndsWith("EventChannelSO"))
|
||||
return ("EVT_", "EVT_{Description},例:EVT_PlayerDied");
|
||||
// 直接匹配
|
||||
if (s_PrefixMap.TryGetValue(t.Name, out var info)) return info;
|
||||
// 向上遍历基类
|
||||
var baseType = t.BaseType;
|
||||
while (baseType != null && baseType != typeof(ScriptableObject))
|
||||
{
|
||||
if (s_PrefixMap.TryGetValue(baseType.Name, out var baseInfo)) return baseInfo;
|
||||
baseType = baseType.BaseType;
|
||||
}
|
||||
return ("", "");
|
||||
}
|
||||
|
||||
// ── 重命名 UI 组件 ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 创建可复用的"重命名资产"操作条(UIToolkit)。
|
||||
/// 重命名成功后以资产 GUID(字符串)调用 onRenamed,调用方可用
|
||||
/// <see cref="FindIndexByGuid{T}"/> 在刷新后的列表中恢复选中。
|
||||
/// </summary>
|
||||
/// <param name="asset">要重命名的资产对象。</param>
|
||||
/// <param name="prefix">自动前缀(如 WPN_),留空则禁用前缀勾选框。</param>
|
||||
/// <param name="convention">命名规范提示文字,留空则不显示。</param>
|
||||
/// <param name="onRenamed">重命名成功后的回调,参数为资产 GUID(重命名不会改变 GUID)。</param>
|
||||
public static VisualElement MakeRenameBar(
|
||||
UnityEngine.Object asset,
|
||||
string prefix,
|
||||
string convention,
|
||||
Action<string> onRenamed = null)
|
||||
{
|
||||
var bar = new VisualElement();
|
||||
bar.AddToClassList("rename-bar");
|
||||
|
||||
// ── 标题行 ────────────────────────────────────────────────────
|
||||
var headerRow = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
|
||||
var titleLbl = new Label("重命名") { style = { unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } };
|
||||
headerRow.Add(titleLbl);
|
||||
if (!string.IsNullOrEmpty(convention))
|
||||
{
|
||||
var hint = new Label($"规范:{convention}");
|
||||
hint.AddToClassList("rename-hint");
|
||||
headerRow.Add(hint);
|
||||
}
|
||||
bar.Add(headerRow);
|
||||
|
||||
// ── 输入行 ────────────────────────────────────────────────────
|
||||
var inputRow = new VisualElement();
|
||||
inputRow.AddToClassList("rename-bar-row");
|
||||
|
||||
var nameField = new TextField { value = asset.name, style = { flexGrow = 1 } };
|
||||
inputRow.Add(nameField);
|
||||
|
||||
bool hasPrefix = !string.IsNullOrEmpty(prefix);
|
||||
var toggle = new Toggle("自动前缀") { value = hasPrefix };
|
||||
toggle.style.marginLeft = 6;
|
||||
toggle.style.marginRight = 6;
|
||||
if (!hasPrefix) toggle.SetEnabled(false);
|
||||
inputRow.Add(toggle);
|
||||
|
||||
var btn = new Button(() =>
|
||||
{
|
||||
string newName = nameField.value.Trim();
|
||||
if (string.IsNullOrEmpty(newName)) return;
|
||||
if (toggle.value && hasPrefix && !newName.StartsWith(prefix, StringComparison.Ordinal))
|
||||
newName = prefix + newName;
|
||||
|
||||
string assetPath = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
EditorUtility.DisplayDialog("重命名失败", "资产路径为空,请先保存资产。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// GUID 在重命名后不变,提前捕获
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
|
||||
// 1. 更新内部序列化名称
|
||||
asset.name = newName;
|
||||
EditorUtility.SetDirty(asset);
|
||||
|
||||
// 2. 重命名文件(只改文件名,不含扩展名)
|
||||
string err = AssetDatabase.RenameAsset(assetPath, newName);
|
||||
if (!string.IsNullOrEmpty(err))
|
||||
{
|
||||
// 回滚内存名称
|
||||
asset.name = System.IO.Path.GetFileNameWithoutExtension(assetPath);
|
||||
EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// 同步输入框显示实际名称
|
||||
nameField.value = asset.name;
|
||||
onRenamed?.Invoke(guid);
|
||||
}) { text = "重命名" };
|
||||
inputRow.Add(btn);
|
||||
|
||||
bar.Add(inputRow);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── SO 资产创建 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +171,68 @@ namespace BaseGames.Editor
|
||||
return asset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 SaveFilePanel 让用户选择名称和路径,交互式创建 SO 资产。
|
||||
/// 取消时返回 null。
|
||||
/// </summary>
|
||||
public static T CreateSOAssetInteractive<T>(string defaultFolder, string defaultName) where T : ScriptableObject
|
||||
{
|
||||
string path = EditorUtility.SaveFilePanelInProject(
|
||||
$"创建 {typeof(T).Name}",
|
||||
defaultName, "asset",
|
||||
$"选择 {typeof(T).Name} 的保存路径",
|
||||
defaultFolder);
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
|
||||
EnsureFolder(System.IO.Path.GetDirectoryName(path)?.Replace('\\', '/') ?? defaultFolder);
|
||||
|
||||
var asset = ScriptableObject.CreateInstance<T>();
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
AssetDatabase.SaveAssets();
|
||||
PingAndSelect(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
// ── 资产删除 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 弹出确认对话框后删除 SO 资产。返回 true 表示已成功删除。
|
||||
/// </summary>
|
||||
public static bool DeleteSOAsset(ScriptableObject asset)
|
||||
{
|
||||
if (asset == null) return false;
|
||||
string path = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
|
||||
bool ok = EditorUtility.DisplayDialog(
|
||||
"确认删除",
|
||||
$"确定要删除「{asset.name}」?\n{path}\n\n此操作不可撤销!",
|
||||
"删除", "取消");
|
||||
if (!ok) return false;
|
||||
|
||||
AssetDatabase.DeleteAsset(path);
|
||||
AssetDatabase.SaveAssets();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 按 GUID 查找 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 在列表中按资产 GUID 查找索引(重命名后 GUID 不变,用于在 Refresh 后恢复选中)。
|
||||
/// 返回 -1 表示未找到。
|
||||
/// </summary>
|
||||
public static int FindIndexByGuid<T>(List<T> list, string guid) where T : UnityEngine.Object
|
||||
{
|
||||
if (string.IsNullOrEmpty(guid)) return -1;
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (list[i] == null) continue;
|
||||
string g = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(list[i]));
|
||||
if (g == guid) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ── 目录工具 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>确保 Assets 相对路径目录存在(不存在则递归创建)。</summary>
|
||||
|
||||
259
Assets/_Game/Scripts/Editor/Shared/SoListPane.cs
Normal file
259
Assets/_Game/Scripts/Editor/Shared/SoListPane.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用 ScriptableObject 列表面板(VisualElement 子类)。
|
||||
/// 提供:搜索栏、[新建] 按钮、带类型徽章的 ListView、右键上下文菜单、GUID 选中追踪。
|
||||
/// </summary>
|
||||
public class SoListPane<T> : VisualElement where T : ScriptableObject
|
||||
{
|
||||
// ── 事件(使用字段委托,允许外部直接赋值替换,避免累积)─────────────
|
||||
public Action<T> SelectionChanged;
|
||||
|
||||
// ── 字段 ─────────────────────────────────────────────────────────────
|
||||
private readonly string _defaultFolder;
|
||||
private readonly string _defaultPrefix;
|
||||
private readonly Func<T, string> _getTypeBadge;
|
||||
|
||||
private List<T> _all = new();
|
||||
private List<T> _filtered = new();
|
||||
private string _search = "";
|
||||
private string _savedGuid = "";
|
||||
|
||||
private ListView _listView;
|
||||
private Label _countLabel;
|
||||
|
||||
// ── 构造 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <param name="defaultFolder">新建资产的默认保存目录。</param>
|
||||
/// <param name="defaultPrefix">新建资产文件名前缀(如 "WPN_")。</param>
|
||||
/// <param name="getTypeBadge">返回每个资产的类型徽章文本;返回 null/空则不显示徽章。</param>
|
||||
public SoListPane(
|
||||
string defaultFolder,
|
||||
string defaultPrefix = "",
|
||||
Func<T, string> getTypeBadge = null)
|
||||
{
|
||||
_defaultFolder = defaultFolder;
|
||||
_defaultPrefix = defaultPrefix;
|
||||
_getTypeBadge = getTypeBadge;
|
||||
|
||||
style.flexGrow = 1;
|
||||
style.flexDirection = FlexDirection.Column;
|
||||
|
||||
BuildUI();
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
// Toolbar
|
||||
var toolbar = new VisualElement();
|
||||
toolbar.style.flexDirection = FlexDirection.Row;
|
||||
toolbar.style.alignItems = Align.Center;
|
||||
toolbar.style.paddingLeft = 6;
|
||||
toolbar.style.paddingRight = 6;
|
||||
toolbar.style.paddingTop = 5;
|
||||
toolbar.style.paddingBottom = 5;
|
||||
toolbar.style.borderBottomWidth = 1;
|
||||
toolbar.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
Add(toolbar);
|
||||
|
||||
var searchField = new TextField();
|
||||
searchField.style.flexGrow = 1;
|
||||
searchField.style.marginRight = 4;
|
||||
searchField.RegisterValueChangedCallback(e => { _search = e.newValue; ApplyFilter(); });
|
||||
toolbar.Add(searchField);
|
||||
|
||||
var btnNew = new Button(OnCreateClicked) { text = "+ 新建" };
|
||||
btnNew.style.height = 20;
|
||||
btnNew.style.paddingLeft = 8;
|
||||
btnNew.style.paddingRight = 8;
|
||||
toolbar.Add(btnNew);
|
||||
|
||||
// ListView
|
||||
_listView = new ListView(_filtered, 24, MakeItem, BindItem);
|
||||
_listView.style.flexGrow = 1;
|
||||
_listView.selectionType = SelectionType.Single;
|
||||
_listView.showAlternatingRowBackgrounds = AlternatingRowBackground.ContentOnly;
|
||||
_listView.onSelectionChange += objects =>
|
||||
{
|
||||
var sel = objects.OfType<T>().FirstOrDefault();
|
||||
if (sel != null) _savedGuid = AssetOperations.GetGuid(sel);
|
||||
SelectionChanged?.Invoke(sel);
|
||||
};
|
||||
Add(_listView);
|
||||
|
||||
// Count footer
|
||||
_countLabel = new Label();
|
||||
_countLabel.style.fontSize = 10;
|
||||
_countLabel.style.opacity = 0.55f;
|
||||
_countLabel.style.paddingLeft = 6;
|
||||
_countLabel.style.paddingBottom = 3;
|
||||
_countLabel.style.paddingTop = 2;
|
||||
Add(_countLabel);
|
||||
}
|
||||
|
||||
// ── ListView 回调 ─────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement MakeItem()
|
||||
{
|
||||
var root = new VisualElement();
|
||||
root.style.flexDirection = FlexDirection.Row;
|
||||
root.style.alignItems = Align.Center;
|
||||
root.style.paddingLeft = 6;
|
||||
root.style.paddingRight = 4;
|
||||
root.style.height = 24;
|
||||
|
||||
// 类型徽章
|
||||
var badge = new Label { name = "badge" };
|
||||
badge.style.fontSize = 10;
|
||||
badge.style.paddingLeft = 4;
|
||||
badge.style.paddingRight = 4;
|
||||
badge.style.paddingTop = 1;
|
||||
badge.style.paddingBottom = 1;
|
||||
badge.style.borderTopLeftRadius = 3;
|
||||
badge.style.borderTopRightRadius = 3;
|
||||
badge.style.borderBottomLeftRadius = 3;
|
||||
badge.style.borderBottomRightRadius = 3;
|
||||
badge.style.backgroundColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
badge.style.marginRight = 5;
|
||||
badge.style.display = DisplayStyle.None;
|
||||
root.Add(badge);
|
||||
|
||||
// 名称标签
|
||||
var nameLabel = new Label { name = "name" };
|
||||
nameLabel.style.flexGrow = 1;
|
||||
root.Add(nameLabel);
|
||||
|
||||
// 右键菜单(通过 userData 获取当前绑定的资产)
|
||||
root.AddManipulator(new ContextualMenuManipulator(evt =>
|
||||
{
|
||||
if (root.userData is not T item) return;
|
||||
evt.menu.AppendAction("在 Project 中定位", _ =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(item);
|
||||
Selection.activeObject = item;
|
||||
});
|
||||
evt.menu.AppendAction("在 Inspector 中打开", _ =>
|
||||
Selection.activeObject = item);
|
||||
evt.menu.AppendSeparator();
|
||||
evt.menu.AppendAction("克隆...", _ =>
|
||||
{
|
||||
var clone = AssetOperations.Clone(item, _defaultFolder);
|
||||
if (clone != null) Refresh(clone);
|
||||
});
|
||||
evt.menu.AppendSeparator();
|
||||
evt.menu.AppendAction("删除", _ =>
|
||||
{
|
||||
if (AssetOperations.Delete(item)) Refresh(null);
|
||||
});
|
||||
}));
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private void BindItem(VisualElement el, int idx)
|
||||
{
|
||||
if (idx < 0 || idx >= _filtered.Count) return;
|
||||
var item = _filtered[idx];
|
||||
el.userData = item;
|
||||
|
||||
el.Q<Label>("name").text = item.name;
|
||||
|
||||
var badge = el.Q<Label>("badge");
|
||||
if (badge != null)
|
||||
{
|
||||
if (_getTypeBadge != null)
|
||||
{
|
||||
var txt = _getTypeBadge(item);
|
||||
if (!string.IsNullOrEmpty(txt))
|
||||
{
|
||||
badge.text = txt;
|
||||
badge.style.display = DisplayStyle.Flex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
badge.style.display = DisplayStyle.None;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部操作 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnCreateClicked()
|
||||
{
|
||||
string name = _defaultPrefix.Length > 0 ? _defaultPrefix + "New" : "New" + typeof(T).Name;
|
||||
var asset = AssetOperations.Create<T>(_defaultFolder, name);
|
||||
if (asset != null) Refresh(asset);
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
_filtered.Clear();
|
||||
foreach (var item in _all)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_search) ||
|
||||
item.name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
_filtered.Add(item);
|
||||
}
|
||||
|
||||
_listView.RefreshItems();
|
||||
_countLabel.text = _all.Count == _filtered.Count
|
||||
? $"{_all.Count} 项"
|
||||
: $"{_filtered.Count} / {_all.Count} 项";
|
||||
|
||||
TryRestoreSelection();
|
||||
}
|
||||
|
||||
private void TryRestoreSelection()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_savedGuid)) return;
|
||||
for (int i = 0; i < _filtered.Count; i++)
|
||||
{
|
||||
if (AssetOperations.GetGuid(_filtered[i]) == _savedGuid)
|
||||
{
|
||||
_listView.SetSelection(i);
|
||||
_listView.ScrollToItem(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>重新加载所有资产,可选择在刷新后选中指定资产。</summary>
|
||||
public void Refresh(T selectAfter = null)
|
||||
{
|
||||
if (selectAfter != null)
|
||||
_savedGuid = AssetOperations.GetGuid(selectAfter);
|
||||
|
||||
_all = AssetOperations.FindAll<T>();
|
||||
_all.Sort((a, b) => string.Compare(a.name, b.name, StringComparison.OrdinalIgnoreCase));
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
/// <summary>强制重建列表视觉(如重命名后需刷新显示)。</summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
_listView.RefreshItems();
|
||||
}
|
||||
|
||||
/// <summary>当前选中的资产;无选中时为 null。</summary>
|
||||
public T Selected =>
|
||||
_listView.selectedIndex >= 0 && _listView.selectedIndex < _filtered.Count
|
||||
? _filtered[_listView.selectedIndex]
|
||||
: null;
|
||||
|
||||
/// <summary>清除列表选中,触发 SelectionChanged(null)。</summary>
|
||||
public void ClearSelection()
|
||||
{
|
||||
_listView.ClearSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Editor/Shared/SoListPane.cs.meta
Normal file
11
Assets/_Game/Scripts/Editor/Shared/SoListPane.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 011ed46e4350a784f88ae3687ce76197
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user