Files
Cielonos/Assets/Scripts/SLSUtilities/SerializedDictionary/SerializedDictionary.cs
2026-05-10 11:47:55 -04:00

510 lines
20 KiB
C#
Raw Permalink 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;
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 TValue GetValueOrDefault(TKey key, TValue defaultValue = default)
{
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
return _lookup.GetValueOrDefault(key, defaultValue);
}
public bool TryGetValue(TKey key, out TValue value)
{
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
return _lookup.TryGetValue(key, out value);
}
public Dictionary<TKey, TValue> ToDictionary()
{
if (_lookup.Count == 0 && _entries.Count > 0) Rebuild();
return new Dictionary<TKey, TValue>(_lookup);
}
// ================= 核心:排序与重建 =================
/// <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 后的参数是 RGBAA=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; }
}
}