498 lines
20 KiB
C#
498 lines
20 KiB
C#
using System;
|
||
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using Sirenix.OdinInspector;
|
||
using UnityEngine;
|
||
|
||
#if UNITY_EDITOR
|
||
using System.Reflection;
|
||
using Sirenix.Utilities.Editor;
|
||
using Sirenix.OdinInspector.Editor;
|
||
using UnityEditor;
|
||
#endif
|
||
|
||
namespace SLSUtilities.General
|
||
{
|
||
[Serializable]
|
||
[HideLabel]
|
||
[LabelWidth(80)]
|
||
[InlineProperty]
|
||
public partial class SerializedDictionary<TKey, TValue, TPair> :
|
||
IEnumerable<KeyValuePair<TKey, TValue>>, ISerializationCallbackReceiver
|
||
where TPair : struct, ISerializedPair<TKey, TValue>
|
||
{
|
||
// ================= 配置与数据 =================
|
||
|
||
[SerializeField, HideInInspector]
|
||
private DuplicateKeyStrategy _duplicateStrategy = DuplicateKeyStrategy.LogError;
|
||
|
||
[LabelText("@SerializedDictionaryHelper.GetDictionaryTitle($property)")]
|
||
[SerializeField]
|
||
[InfoBox("检测到重复的 Key!重复项已被标记为红色,运行时将根据策略被忽略或报错。", InfoMessageType.Error, VisibleIf = "HasDuplicates")]
|
||
[ListDrawerSettings(ShowIndexLabels = false, OnTitleBarGUI = "DrawToolbar", OnBeginListElementGUI = "DrawListElementBackground")]
|
||
[Searchable]
|
||
[PropertyOrder(1)]
|
||
protected List<TPair> _entries = new List<TPair>();
|
||
|
||
// ================= 运行时缓存 =================
|
||
|
||
// 1. 查找表:提供 O(1) 访问
|
||
protected Dictionary<TKey, TValue> _lookup = new Dictionary<TKey, TValue>();
|
||
|
||
// 2. 顺序表:提供 O(n) 遍历顺序
|
||
// 我们只存 Key,因为 Value 可以去 lookup 查,节省内存
|
||
protected List<TKey> _orderedKeys = new List<TKey>();
|
||
|
||
// ================= 公共接口 =================
|
||
|
||
[SerializeField, HideInInspector]
|
||
public float keyColumnWidth = 0.5f;
|
||
public int Count => _lookup.Count;
|
||
|
||
public TValue this[TKey key]
|
||
{
|
||
get
|
||
{
|
||
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild(); // 懒加载保护
|
||
return _lookup[key];
|
||
}
|
||
set
|
||
{
|
||
if (_lookup.Count == 0) Rebuild();
|
||
|
||
// 如果是新 Key,需要加入顺序表
|
||
if (!_lookup.ContainsKey(key))
|
||
{
|
||
_orderedKeys.Add(key);
|
||
}
|
||
_lookup[key] = value;
|
||
}
|
||
}
|
||
|
||
public bool ContainsKey(TKey key)
|
||
{
|
||
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
|
||
return _lookup.ContainsKey(key);
|
||
}
|
||
|
||
public bool TryGetValue(TKey key, out TValue value)
|
||
{
|
||
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
|
||
return _lookup.TryGetValue(key, out value);
|
||
}
|
||
|
||
// ================= 核心:排序与重建 =================
|
||
|
||
/// <summary>
|
||
/// 重建并排序。
|
||
/// </summary>
|
||
/// <param name="sortMethod">
|
||
/// 自定义排序方法。
|
||
/// 例如按 Value 降序:(a, b) => b.Value.CompareTo(a.Value)
|
||
/// </param>
|
||
public void Rebuild(Comparison<KeyValuePair<TKey, TValue>> sortMethod = null)
|
||
{
|
||
_lookup.Clear();
|
||
_orderedKeys.Clear();
|
||
|
||
// 1. 先构建基础数据
|
||
foreach (var entry in _entries)
|
||
{
|
||
TKey key = entry.Key;
|
||
// 过滤空 Key
|
||
if (key == null || (key is string s && string.IsNullOrEmpty(s))) continue;
|
||
|
||
if (_lookup.ContainsKey(key))
|
||
{
|
||
HandleDuplicate(key, entry.Value);
|
||
}
|
||
else
|
||
{
|
||
_lookup.Add(key, entry.Value);
|
||
_orderedKeys.Add(key);
|
||
}
|
||
}
|
||
|
||
// 2. 如果提供了排序方法,对 _orderedKeys 进行排序
|
||
if (sortMethod != null)
|
||
{
|
||
// List.Sort 默认不支持 KeyValuePair 的 Comparison,我们需要转接一下
|
||
_orderedKeys.Sort((keyA, keyB) =>
|
||
{
|
||
var pairA = new KeyValuePair<TKey, TValue>(keyA, _lookup[keyA]);
|
||
var pairB = new KeyValuePair<TKey, TValue>(keyB, _lookup[keyB]);
|
||
return sortMethod(pairA, pairB);
|
||
});
|
||
}
|
||
}
|
||
|
||
private void HandleDuplicate(TKey key, TValue newValue)
|
||
{
|
||
switch (_duplicateStrategy)
|
||
{
|
||
case DuplicateKeyStrategy.LogError:
|
||
Debug.LogError($"[SerializedDictionary] 重复 Key: '{key}'。");
|
||
break;
|
||
case DuplicateKeyStrategy.Overwrite:
|
||
_lookup[key] = newValue;
|
||
break;
|
||
// Ignore: do nothing
|
||
}
|
||
}
|
||
|
||
// ================= 序列化回调 =================
|
||
|
||
public void OnBeforeSerialize() { }
|
||
public void OnAfterDeserialize()
|
||
{
|
||
// 编辑器下不自动构建,为了性能。
|
||
// 运行时通常在 Awake/OnEnable 显式调用 Rebuild(sorter)。
|
||
_lookup.Clear();
|
||
_orderedKeys.Clear();
|
||
}
|
||
|
||
// ================= 迭代器 (核心魔法) =================
|
||
|
||
// 这里我们不遍历 Dictionary,而是遍历 _orderedKeys
|
||
// 这样就能保证 foreach 的顺序是我们排好序的顺序
|
||
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
|
||
{
|
||
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
|
||
|
||
foreach (var key in _orderedKeys)
|
||
{
|
||
yield return new KeyValuePair<TKey, TValue>(key, _lookup[key]);
|
||
}
|
||
}
|
||
|
||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||
}
|
||
|
||
#if UNITY_EDITOR
|
||
public partial class SerializedDictionary<TKey, TValue, TPair>
|
||
{
|
||
// 缓存重复的 Key,避免在绘制每一行时重复计算 O(N^2)
|
||
private HashSet<TKey> _duplicateKeysCache = new HashSet<TKey>();
|
||
private bool HasDuplicates => _duplicateKeysCache.Count > 0;
|
||
|
||
[OnInspectorGUI]
|
||
private void UpdateDuplicateCache()
|
||
{
|
||
// 简单高效的统计:找到所有出现次数 > 1 的 Key
|
||
// 这里使用 LINQ GroupBy,对于几百个元素的配置表性能完全没问题
|
||
// 如果列表极大(上千条),可能需要优化,但通常配置表不会这么大
|
||
_duplicateKeysCache.Clear();
|
||
var duplicates = _entries.Select(e => e.Key)
|
||
.Where(k => k != null).GroupBy(k => k)
|
||
.Where(g => g.Count() > 1).Select(g => g.Key);
|
||
|
||
foreach (var key in duplicates)
|
||
{
|
||
_duplicateKeysCache.Add(key);
|
||
}
|
||
}
|
||
|
||
// ★ 核心魔法:绘制背景 ★
|
||
// 这个方法会被 List Drawer 在绘制每个元素前调用
|
||
private void DrawListElementBackground(int index)
|
||
{
|
||
if (index < 0 || index >= _entries.Count) return;
|
||
TKey key = _entries[index].Key;
|
||
|
||
// 如果这个 Key 是重复的
|
||
if (key != null && _duplicateKeysCache.Contains(key))
|
||
{
|
||
// 获取当前行的高度和矩形区域
|
||
Rect rect = GUIHelper.GetCurrentLayoutRect();
|
||
|
||
// 绘制红色背景 (Color 后的参数是 RGBA,A=0.3f 让它半透明不遮挡文字)
|
||
SirenixEditorGUI.DrawSolidRect(rect, new Color(1f, 0.2f, 0.2f, 0.3f));
|
||
}
|
||
}
|
||
|
||
private string _keyLabelCache = "";
|
||
private string _valueLabelCache = "";
|
||
private float _keyWidthRatioCache = 0.5f;
|
||
|
||
protected virtual void DrawHeader()
|
||
{
|
||
|
||
}
|
||
|
||
// 2. 新增虚方法,作为 Toolbar 的主入口
|
||
protected virtual void DrawToolbar(InspectorProperty listProperty)
|
||
{
|
||
// 1. 绘制去重策略按钮
|
||
DrawDuplicateStrategySelector();
|
||
|
||
// 2. ★ 核心逻辑:绘制自定义 Attribute 按钮 ★
|
||
// listProperty.Parent 就是持有这个字典的字段 (例如 characterAttributes)
|
||
var dictProperty = listProperty.Parent;
|
||
if (dictProperty == null) return;
|
||
|
||
// 获取该字段上所有的 [DictionaryToolbarAction]
|
||
// 需要引用 System.Linq
|
||
var actions = dictProperty.Attributes.OfType<ToolbarButtonAttribute>();
|
||
|
||
foreach (var action in actions)
|
||
{
|
||
if (SirenixEditorGUI.ToolbarButton(action.Icon))
|
||
{
|
||
InvokeAction(action, dictProperty);
|
||
}
|
||
|
||
Rect btnRect = GUILayoutUtility.GetLastRect();
|
||
if (!string.IsNullOrEmpty(action.Tooltip))
|
||
{
|
||
GUI.Label(btnRect, new GUIContent("", action.Tooltip));
|
||
}
|
||
}
|
||
|
||
if (SirenixEditorGUI.ToolbarButton(SdfIconType.ArrowLeftRight)) // 换了个更贴切的图标
|
||
{
|
||
GenericMenu menu = new GenericMenu();
|
||
menu.AddItem(new GUIContent("10%"), Mathf.Approximately(keyColumnWidth, 0.1f), () => keyColumnWidth = 0.1f);
|
||
menu.AddItem(new GUIContent("30%"), Mathf.Approximately(keyColumnWidth, 0.3f), () => keyColumnWidth = 0.3f);
|
||
menu.AddItem(new GUIContent("50%"), Mathf.Approximately(keyColumnWidth, 0.5f), () => keyColumnWidth = 0.5f);
|
||
menu.AddItem(new GUIContent("70%"), Mathf.Approximately(keyColumnWidth, 0.7f), () => keyColumnWidth = 0.7f);
|
||
menu.AddItem(new GUIContent("90%"), Mathf.Approximately(keyColumnWidth, 0.9f), () => keyColumnWidth = 0.9f);
|
||
menu.ShowAsContext();
|
||
}
|
||
|
||
_keyLabelCache = dictProperty.GetAttribute<SerializedDictionarySettingsAttribute>()?.KeyLabel ?? string.Empty;
|
||
_valueLabelCache = dictProperty.GetAttribute<SerializedDictionarySettingsAttribute>()?.ValueLabel ?? string.Empty;
|
||
_keyWidthRatioCache = (dictProperty.ValueEntry.WeakSmartValue as SerializedDictionary<TKey, TValue>)?.keyColumnWidth ?? 0.5f;
|
||
|
||
DrawTitle(listProperty);
|
||
}
|
||
|
||
protected void DrawTitle(InspectorProperty listProperty)
|
||
{
|
||
if (!listProperty.State.Expanded || _entries.Count == 0) return;
|
||
if (string.IsNullOrEmpty(_keyLabelCache) && string.IsNullOrEmpty(_valueLabelCache)) return;
|
||
GUILayout.EndHorizontal();
|
||
|
||
float keyWidthRatio = _keyWidthRatioCache;
|
||
float totalWidth = EditorGUIUtility.currentViewWidth - 80f + 50f;
|
||
float keyPixelWidth = totalWidth * keyWidthRatio;
|
||
float valuePixelWidth = totalWidth - keyPixelWidth;
|
||
|
||
// 4. 绘制背景条 (使用深色背景,模拟 Toolbar 的延伸)
|
||
// 预留 22px 高度
|
||
Rect headerRect = GUILayoutUtility.GetRect(totalWidth, 20f);
|
||
Rect insideRect = new Rect(headerRect.x + 1, headerRect.y, headerRect.width - 2, headerRect.height);
|
||
SirenixEditorGUI.DrawSolidRect(insideRect, Color.gray8);
|
||
SirenixEditorGUI.DrawSolidRect(headerRect, SirenixGUIStyles.BorderColor);
|
||
|
||
// 5. 绘制文字 (使用 BeginIndentedHorizontal 确保和 List 内容对齐)
|
||
SirenixEditorGUI.BeginIndentedHorizontal();
|
||
{
|
||
// 计算 Label 区域
|
||
Rect contentRect = new Rect(headerRect.x, headerRect.y, headerRect.width, headerRect.height);
|
||
Rect keyRect = new Rect(contentRect.x, contentRect.y, keyPixelWidth, contentRect.height);
|
||
Rect valueRect = new Rect(keyRect.xMax, contentRect.y, valuePixelWidth, contentRect.height);
|
||
|
||
// Draw Key
|
||
if (!string.IsNullOrEmpty(_keyLabelCache))
|
||
{
|
||
GUI.Label(keyRect, _keyLabelCache, SirenixGUIStyles.CenteredWhiteMiniLabel);
|
||
}
|
||
|
||
// Draw Splitter
|
||
SirenixEditorGUI.DrawSolidRect(new Rect(keyRect.xMax, keyRect.y, 1, keyRect.height), SirenixGUIStyles.BorderColor);
|
||
|
||
// Draw Value
|
||
if (!string.IsNullOrEmpty(_valueLabelCache))
|
||
{
|
||
GUI.Label(valueRect, _valueLabelCache, SirenixGUIStyles.CenteredWhiteMiniLabel);
|
||
}
|
||
}
|
||
SirenixEditorGUI.EndIndentedHorizontal();
|
||
|
||
// =======================================================
|
||
// ★ 恢复布局 ★
|
||
// =======================================================
|
||
// 我们必须重新开启一个 Horizontal 布局。
|
||
// 否则 Odin 在 DrawToolbar 结束后尝试关闭布局时会报错(因为我们已经手动关闭了)。
|
||
GUILayout.BeginHorizontal();
|
||
}
|
||
|
||
private void InvokeAction(ToolbarButtonAttribute action, InspectorProperty dictProperty)
|
||
{
|
||
string methodName = action.MethodName;
|
||
|
||
// 获取包含该方法的对象实例 (例如 EditorBaseCollection 的实例)
|
||
object targetObject = dictProperty.ParentValues[0];
|
||
Type targetType = targetObject.GetType();
|
||
|
||
// 反射查找方法
|
||
MethodInfo method = targetType.GetMethod(methodName,
|
||
BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
|
||
|
||
if (method == null)
|
||
{
|
||
Debug.LogError($"[DictionaryToolbarAction] 在 {targetType.Name} 中找不到方法 '{methodName}'");
|
||
return;
|
||
}
|
||
|
||
// 执行方法 (支持传参)
|
||
var parameters = method.GetParameters();
|
||
if (parameters.Length == 0)
|
||
{
|
||
method.Invoke(targetObject, null);
|
||
}
|
||
else if (parameters.Length == 1)
|
||
{
|
||
// 如果方法有一个参数,尝试传入字典本身
|
||
// ValueEntry.WeakSmartValue 是字典的实例
|
||
method.Invoke(targetObject, new object[] { dictProperty.ValueEntry.WeakSmartValue });
|
||
}
|
||
else
|
||
{
|
||
Debug.LogError($"[DictionaryToolbarAction] 方法 '{methodName}' 参数过多。仅支持无参或接收字典参数。");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 这个方法会被 Odin 在绘制 List 标题栏时调用
|
||
/// </summary>
|
||
private void DrawDuplicateStrategySelector()
|
||
{
|
||
if (SirenixEditorGUI.ToolbarButton(SdfIconType.Gear))
|
||
{
|
||
// 当按钮被点击时,创建一个通用菜单 (GenericMenu)
|
||
GenericMenu menu = new GenericMenu();
|
||
|
||
// 遍历枚举的所有选项,添加到菜单中
|
||
foreach (DuplicateKeyStrategy strategy in Enum.GetValues(typeof(DuplicateKeyStrategy)))
|
||
{
|
||
// 添加菜单项:当用户点击某一项时,执行 Lambda 表达式修改值
|
||
menu.AddItem(new GUIContent(strategy.ToString()), _duplicateStrategy == strategy, () =>
|
||
{
|
||
_duplicateStrategy = strategy;
|
||
// 强制标记脏数据,确保 Unity 保存修改
|
||
// 注意:在 ScriptableObject 中可能不需要显式 SetDirty,但在某些嵌套结构中是必要的
|
||
});
|
||
}
|
||
|
||
// 在鼠标位置显示菜单
|
||
menu.ShowAsContext();
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|
||
|
||
/// <summary>
|
||
/// 通用版序列化字典。
|
||
/// 不需要定义 Pair 结构体,直接使用:SerializedDictionary<string, float>
|
||
/// 保留了排序、Toolbar 策略和自定义标签等所有功能。
|
||
/// </summary>
|
||
[Serializable]
|
||
public class SerializedDictionary<TKey, TValue> : SerializedDictionary<TKey, TValue, SerializedDictionary<TKey, TValue>.SimplePair>
|
||
{
|
||
[Serializable]
|
||
public struct SimplePair : ISerializedPair<TKey, TValue>
|
||
{
|
||
[HideLabel]
|
||
public TKey key;
|
||
|
||
[HideLabel]
|
||
public TValue value;
|
||
|
||
// 接口实现
|
||
public TKey Key => key;
|
||
public TValue Value => value;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
#if UNITY_EDITOR
|
||
public static class SerializedDictionaryHelper
|
||
{
|
||
public static object GetDictionaryFromKey(InspectorProperty keyProperty)
|
||
{
|
||
if (keyProperty == null) return null;
|
||
var dictProperty = keyProperty.Parent?.Parent?.Parent?.Parent;
|
||
if (dictProperty == null) return null;
|
||
return dictProperty.ValueEntry.WeakSmartValue;
|
||
}
|
||
|
||
public static readonly GUIStyle NoMargin = new GUIStyle
|
||
{
|
||
margin = new RectOffset(0, 0, 0, 0),
|
||
padding = new RectOffset(0, 0, 0, 0),
|
||
overflow = new RectOffset(0, 0, 0, 0)
|
||
};
|
||
|
||
/// <summary>
|
||
/// 这是一个辅助方法,供 Odin 的 @表达式 调用
|
||
/// </summary>
|
||
public static string GetDictionaryTitle(InspectorProperty listProperty)
|
||
{
|
||
// listProperty 是 _entries 列表
|
||
// listProperty.Parent 是 SerializedDictionary 实例本身的 Property
|
||
var dictProperty = listProperty.Parent;
|
||
|
||
if (dictProperty == null) return "Dictionary";
|
||
|
||
// 1. 优先查找 [DictionaryTitle]
|
||
// 在 C# 代码里,我们可以随意使用 GetAttribute<T> 泛型扩展方法
|
||
var titleAttr = dictProperty.GetAttribute<DictionaryTitleAttribute>();
|
||
if (titleAttr != null) return titleAttr.Title;
|
||
|
||
// 2. 其次查找 [LabelText]
|
||
var labelAttr = dictProperty.GetAttribute<LabelTextAttribute>();
|
||
if (labelAttr != null) return labelAttr.Text;
|
||
|
||
// 3. 最后使用 NiceName (变量名美化)
|
||
return dictProperty.NiceName;
|
||
}
|
||
}
|
||
|
||
// 注意:这里的泛型声明 <TKey, TValue> 是为了匹配目标类型
|
||
// 目标类型是:SerializedDictionary<TKey, TValue>.SimplePair
|
||
public class SerializedDictionarySimplePairDrawer<TKey, TValue> : OdinValueDrawer<SerializedDictionary<TKey, TValue>.SimplePair>
|
||
{
|
||
protected override void DrawPropertyLayout(GUIContent label)
|
||
{
|
||
var property = this.Property.Parent.Parent;
|
||
var dictionary = property.ValueEntry.WeakSmartValue as SerializedDictionary<TKey, TValue>;
|
||
float keyWidthRatio = dictionary?.keyColumnWidth ?? 0.5f;
|
||
|
||
//var settings = property.GetAttribute<SerializedDictionarySettingsAttribute>();
|
||
float totalWidth = EditorGUIUtility.currentViewWidth - 80f;
|
||
float keyPixelWidth = totalWidth * keyWidthRatio;
|
||
|
||
SirenixEditorGUI.BeginHorizontalPropertyLayout(label);
|
||
{
|
||
var keyProp = this.Property.Children["key"];
|
||
var valueProp = this.Property.Children["value"];
|
||
|
||
GUILayout.BeginVertical(SerializedDictionaryHelper.NoMargin, GUILayout.Width(keyPixelWidth));
|
||
keyProp.Draw();
|
||
GUILayout.EndVertical();
|
||
|
||
//绘制分割线
|
||
Rect dividerRect = new Rect(GUILayoutUtility.GetLastRect().xMax, GUILayoutUtility.GetLastRect().y, 1, GUILayoutUtility.GetLastRect().height);
|
||
SirenixEditorGUI.DrawSolidRect(dividerRect, SirenixGUIStyles.BorderColor);
|
||
|
||
GUILayout.BeginVertical(SerializedDictionaryHelper.NoMargin);
|
||
valueProp.Draw();
|
||
GUILayout.EndVertical();
|
||
}
|
||
SirenixEditorGUI.EndHorizontalPropertyLayout();
|
||
}
|
||
}
|
||
#endif
|
||
|
||
public enum DuplicateKeyStrategy { LogError, Ignore, Overwrite }
|
||
|
||
public interface ISerializedPair<TKey, TValue>
|
||
{
|
||
TKey Key { get; }
|
||
TValue Value { get; }
|
||
}
|
||
} |