概要
Enum型の要素をキーにしたディクショナリ用のジェネリッククラス EnumMap<TEnum, TValue> を作りました。
それ専用のプロパティードロワーも作りました。
経緯
指定したEnumの各要素に何かを割り当てたいことがよくあると思います。
Enum型は数値なので、自作コンポーネントやスクリプタブルオブジェクトの SerializeField にしたとき、リストとかで代用できる場合も多いですが、Enum要素との紐づけはコード側で担保する必要があります。
- どのEnum要素が何番かインスペクタ上では確認できない
- Enum要素が追加されたとき値がずれても気づきにくい
- 値指定されたEnumだとコードでの紐づけも複雑に
- リストUIだと追加・削除が自由にできて、手違いで不一致が起きやすい
といった問題があると思います。
Enum要素をキーにした Dictionary (連想配列)を利用することで一部の問題は解決できますが、ご存じのとおり Unity は Dictionary をそのまま SerializeField できず、制限があります。
なのでEnumMap<TEnum,TValue>というジェネリック型と専用のCustomPropertyDrawerを実装して足りないところを補いました。
用例
使用例として、1. enum Element 型と Color を、 2. enum GameSpeed 型と float を、それぞれマップして設定を保持するクラスを書きました。
using UnityEngine;
public enum Element
{
Fire, Water, Wind, Earth, Light, Darkness
}
public enum GameSpeed
{
Slow, Normal, Faster, Fastest
}
public class SampleComponent : MonoBehaviour
{
[SerializeField] EnumMap<Element, Color> elementColors;
[SerializeField] EnumMap<GameSpeed, float> timeScales;
}
インスペクタ上ではこのように表示されます。
メリット:
- 各要素のラベルとしてEnum要素名を表示するので一目瞭然
- Enum要素が追加・削除されたとき自動追従、未設定項目がわかりやすい
- 紐づけはカプセル化、Enum名前で記録してるので要素の追加削除でもずれない
- 実行時はDictionary<TEnum,TValue>化するのでそれなりに高速
デメリット/注意点:
- シリアライズ用のリストと実行時用のディクショナリで二重にメモリを消費する
- Enum要素名変更すると起動時エラーになる
(ただし値がずれたまま気づかないよりマシで、Debug表示にして修正できる)
実装
EnumMap<TEnum,TValue> Enumをキーにした連想配列
キーとなるEnum型と任意の値型を指定できるジェネリッククラスです。
※シリアライズするにはTValueはシリアライズ可能なクラスである必要があります。
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
/// <summary>
/// Enumをキーにしたディクショナリ型(ジェネリック)
/// </summary>
/// <typeparam name="TEnum"></typeparam>
/// <typeparam name="TValue"></typeparam>
[Serializable]
public class EnumMap<TEnum, TValue> : ISerializationCallbackReceiver where TEnum : Enum
{
[Serializable]
public struct Entry
{
public string key; // enum 名
public TValue value; // 対応する値
}
// シリアライズ用(キーは文字列として保持)
[SerializeField] private List<Entry> entries = new List<Entry>();
// ランタイム検索用
private Dictionary<TEnum, TValue> dict = new Dictionary<TEnum, TValue>();
// ランタイム用インデクサ
public TValue this[TEnum key]
{
get
{
dict.TryGetValue(key, out var val);
return val;
}
set
{
dict[key] = value;
}
}
#region ISerializationCallbackReceiver
// シリアライズ前に Dictionary の内容をリストに書き戻す
public void OnBeforeSerialize()
{
// パースできない要素をキーに?をつけて残しておく
var newList = entries.Where(e => !Enum.TryParse(typeof(TEnum), e.key, out _))
.Select(e => ModifyErroredKey(e))
.ToList();
// Enum順序を維持して自動補完しつつ dictionary から list を構築
var enumValues = Enum.GetValues(typeof(TEnum));
foreach (var e in enumValues)
{
var key = (TEnum)e;
if (dict.TryGetValue(key, out var val))
{
newList.Add(new Entry { key = key.ToString(), value = val });
}
else
{
newList.Add(new Entry { key = key.ToString(), value = default });
}
}
entries = newList;
}
// デシリアライズ後に Dictionary を再構築
public void OnAfterDeserialize()
{
// パースできない要素をキーに?をつけて残しておく
var newList = new List<Entry>();
var newDict = new Dictionary<TEnum, TValue>();
// 有効なキーのみで仮構築、変換失敗したものだけ先に新リストに追加
foreach (var e in entries)
{
if (Enum.TryParse(typeof(TEnum), e.key, out var key))
{
newDict[(TEnum)key] = e.value;
}
else
{
Debug.LogError($"Cannot parse '{e.key}' as {typeof(TEnum)}");
// default 値でない場合は、修正できるように残す
if (!(e.value?.Equals(default) ?? false))
{
newList.Add( ModifyErroredKey(e) );
}
}
}
// リストの追加順をEnumの列挙順にあわせて自動補完しつつ再構成
var enumValues = Enum.GetValues(typeof(TEnum));
foreach (var e in enumValues)
{
var key = (TEnum)e;
if (newDict.TryGetValue(key, out var val))
{
newList.Add(new Entry { key = key.ToString(), value = val });
}
else
{
newDict[key] = default;
newList.Add(new Entry { key = key.ToString(), value = default });
}
}
entries = newList;
dict = newDict;
}
private Entry ModifyErroredKey(Entry e)
{
if (e.key.StartsWith("?")) return e;
return new Entry() { key = "?" + e.key, value = e.value };
}
#endregion
// Inspector などで全要素リストを直接取得したい場合用
public IReadOnlyList<Entry> Entries => entries;
}
内部では Entry というオブジェクトで Enum名と TValue をリストとして保持、シリアライズしています。Enum名で保存しているため、Enum型に要素を追加したり番号割り当てを変えてもズレることはありません。
一方デシリアライズ時には Dictionary<TEnum,TValue> に変換して、TEnum型から直接対応する値を取得できるようにしています。毎回 Enum名をパースする必要はなく、それなりに高速です。
EnumMapDrawer エディタ拡張(PropertyDrawer)
EnumMap 型のためのカスタムプロパティドロワーです。Enum要素を一覧で表示しつつ、ラベルとしてキーとなるEnum値の名前を表示します。
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
/// <summary>
/// EnumMap型プロパティのカスタムドロワー
/// </summary>
[CustomPropertyDrawer(typeof(EnumMap<,>), true)]
public class EnumMapDrawer : PropertyDrawer
{
// 折りたたみ状態保存
private static readonly Dictionary<string, bool> foldoutStates = new Dictionary<string, bool>();
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
SerializedProperty entriesProp = property.FindPropertyRelative("entries");
if (entriesProp == null) return EditorGUIUtility.singleLineHeight;
string key = property.propertyPath;
var height = EditorGUIUtility.singleLineHeight;
bool expanded = foldoutStates.ContainsKey(key) && foldoutStates[key];
if (!expanded)
{
return height;
}
// 各要素それぞれの高さを加算
for (int i = 0; i < entriesProp.arraySize; i++)
{
var entryProp = entriesProp.GetArrayElementAtIndex(i);
height += CalcEntryHeight(entryProp);
}
return height;
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
string key = property.propertyPath;
if (!foldoutStates.ContainsKey(key))
foldoutStates[key] = false;
EditorGUI.BeginProperty(position, label, property);
// メインラベル + Foldout描画
Rect foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight);
var foldout = EditorGUI.Foldout(
foldoutRect,
foldoutStates[key], label, true);
foldoutStates[key] = foldout;
if (!foldout)
{
return;
}
// entries リストを取得
SerializedProperty entriesProp = property.FindPropertyRelative("entries");
if (entriesProp == null)
{
EditorGUI.EndProperty();
return;
}
var target = property.GetTarget<ISerializationCallbackReceiver>();
if (target != null && entriesProp.arraySize <= 0)
{
// 初めて描画するとき、要素自動補完を強制する
target.OnAfterDeserialize();
}
var posY = foldoutRect.yMax + EditorGUIUtility.standardVerticalSpacing;
int prevIndent = EditorGUI.indentLevel;
EditorGUI.indentLevel = prevIndent + 1;
// 行ごとに描画
for (int i = 0; i < entriesProp.arraySize; i++)
{
var entryProp = entriesProp.GetArrayElementAtIndex(i);
var keyProp = entryProp.FindPropertyRelative("key");
var valueProp = entryProp.FindPropertyRelative("value");
var height = CalcEntryHeight(entryProp);
Rect line = new Rect(position.x, posY, position.width, height);
posY += height;
// ラベルをenum名で上書きして描画
EditorGUI.PropertyField(line, valueProp, new GUIContent(keyProp.stringValue), true);
}
EditorGUI.indentLevel = prevIndent;
EditorGUI.EndProperty();
}
private float CalcEntryHeight(SerializedProperty property)
{
var keyProp = property.FindPropertyRelative("key");
var valueProp = property.FindPropertyRelative("value");
var height = EditorGUI.GetPropertyHeight(keyProp, true);
if (valueProp != null)
{
var h = EditorGUI.GetPropertyHeight(valueProp, true); // value の分(展開込み)
height = Mathf.Max(h, height);
}
return height + EditorGUIUtility.standardVerticalSpacing;
}
}
PropertyDrawerUtility SerializedProperty用の拡張メソッド
配列やリストの SerializedProperty から、実際の対象オブジェクトを取得するのは若干面倒です。
こちらで紹介されてたコードから必要な部分を拝借して拡張メソッドとして EnumMapDrawer から利用しています。
using UnityEditor;
using System.Reflection;
public static class PropertyDrawerUtility
{
public static T GetTarget<T>(this SerializedProperty prop)
{
if (prop == null) return default(T);
var path = prop.propertyPath.Replace(".Array.data[", "[");
object obj = prop.serializedObject.targetObject;
var elements = path.Split('.');
foreach (var element in elements)
{
if (element.Contains("["))
{
var elementName = element.Substring(0, element.IndexOf("["));
var index = System.Convert.ToInt32(element.Substring(element.IndexOf("[")).Replace("[", "").Replace("]", ""));
obj = GetValue_Imp(obj, elementName, index);
}
else
{
obj = GetValue_Imp(obj, element);
}
}
return (T)obj;
}
private static object GetValue_Imp(object source, string name)
{
if (source == null)
return null;
var type = source.GetType();
while (type != null)
{
var f = type.GetField(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
if (f != null)
return f.GetValue(source);
var p = type.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (p != null)
return p.GetValue(source, null);
type = type.BaseType;
}
return null;
}
private static object GetValue_Imp(object source, string name, int index)
{
var enumerable = GetValue_Imp(source, name) as System.Collections.IEnumerable;
if (enumerable == null) return null;
var enm = enumerable.GetEnumerator();
//while (index-- >= 0)
// enm.MoveNext();
//return enm.Current;
for (int i = 0; i <= index; i++)
{
if (!enm.MoveNext()) return null;
}
return enm.Current;
}
}

