Reference CultCache from the Studio at compile time; add drawers
Cut 6. CultCache Studio 1.0.0 -> 1.1.0. Deleted: the CultCacheBridge string-reflection bridge (type lookups by name, GetMethod/GetProperty invocation, PullOnOpen, FlushAsync(bool)), its DescriptorInfo/CatalogMemberInfo/RecordInfo shadows, and the disabled-text-field fallback in DrawValue. The window calls CultCacheMessagePack.Create, UpsertAsync(Type, object, key), Remove, FlushAsync, IsDirty, Registry.AllDescriptors, AllStoredDocuments and BackingStores directly through GameCult.CultLib. Window: single-file and directory stores, optional read-only open with edits disabled and the reason shown, document types from the registry, records by [CultName] with key fallback including subclasses, Add, Duplicate (MessagePack round trip), Delete, immediate upsert on edit, Save = flush, Dirty indicator. An absent [CultGlobal] shows as absent with an explicit Create; nothing is invented. Opening and closing never writes. Drawers (new CultCacheStudioDrawers.cs): dictionaries with recursive keys and values, abstract/interface members through their MessagePack [Union] declarations only, CultRecordRef<T> record picker plus raw key, CultMath float2/3/4, double2/3, int2, bool2, quaternion, rect, Color32, enums including flags, lists and arrays with add/remove, nested keyed types, a red error row for unclaimed types, and the [CultInspectorDrawer(typeof(T))] extension point discovered through TypeCache (ICultInspectorDrawer). Existing inspector attributes keep working. package.json depends on org.gamecult.cultlib 1.0.57 (Cut 7) and org.gamecult.cultmath 0.1.2. No Unity.Mathematics reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
47c7111897
commit
8be174b82c
8 changed files with 766 additions and 734 deletions
|
|
@ -1398,7 +1398,7 @@ namespace GameCult.Caching
|
|||
return true;
|
||||
});
|
||||
|
||||
// soft is dead. CultCacheStudioWindow reflects on FlushAsync(bool) until Cut 6 rewrites it.
|
||||
// soft is ignored.
|
||||
public Task FlushAsync(bool soft = false)
|
||||
{
|
||||
FlushAllBackingStores();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,519 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using GameCult.Caching;
|
||||
using MessagePack;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using CM = CultMath;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace GameCult.Unity.Caching.Editor
|
||||
{
|
||||
// Returns the member's next value; mutate reference values in place or return a new one.
|
||||
public interface ICultInspectorDrawer
|
||||
{
|
||||
object Draw(string label, object value, MemberInfo member);
|
||||
}
|
||||
|
||||
internal sealed class CultInspector
|
||||
{
|
||||
private static Dictionary<Type, ICultInspectorDrawer> _drawers;
|
||||
private static readonly Dictionary<Type, MemberInfo> NameMembers = new Dictionary<Type, MemberInfo>();
|
||||
private static GUIStyle _errorStyle;
|
||||
|
||||
private readonly CultCache _cache;
|
||||
private readonly Dictionary<string, bool> _foldouts = new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Type, MemberInfo[]> _members = new Dictionary<Type, MemberInfo[]>();
|
||||
|
||||
public CultInspector(CultCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public CultStoredDocument[] Records { get; set; } = Array.Empty<CultStoredDocument>();
|
||||
|
||||
private static GUIStyle ErrorStyle => _errorStyle ??= new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, .35f, .3f) } };
|
||||
|
||||
private static Dictionary<Type, ICultInspectorDrawer> Drawers
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_drawers != null) return _drawers;
|
||||
_drawers = new Dictionary<Type, ICultInspectorDrawer>();
|
||||
foreach (var type in TypeCache.GetTypesWithAttribute<CultInspectorDrawerAttribute>())
|
||||
{
|
||||
var memberType = type.GetCustomAttribute<CultInspectorDrawerAttribute>().MemberType;
|
||||
if (!typeof(ICultInspectorDrawer).IsAssignableFrom(type) || type.IsAbstract || type.GetConstructor(Type.EmptyTypes) == null)
|
||||
Debug.LogError($"{type.FullName} is marked [CultInspectorDrawer] but is not a concrete ICultInspectorDrawer with a parameterless constructor.");
|
||||
else if (_drawers.ContainsKey(memberType))
|
||||
Debug.LogError($"{type.FullName} and {_drawers[memberType].GetType().FullName} both draw {memberType.FullName}.");
|
||||
else
|
||||
_drawers[memberType] = (ICultInspectorDrawer)Activator.CreateInstance(type);
|
||||
}
|
||||
|
||||
return _drawers;
|
||||
}
|
||||
}
|
||||
|
||||
public static string RecordLabel(CultStoredDocument record)
|
||||
{
|
||||
var type = record.Descriptor.DocumentType;
|
||||
if (!NameMembers.TryGetValue(type, out var member))
|
||||
{
|
||||
member = record.Descriptor.NameMember == null
|
||||
? null
|
||||
: type.GetMember(record.Descriptor.NameMember, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault();
|
||||
NameMembers[type] = member;
|
||||
}
|
||||
|
||||
var name = member == null ? null : Get(member, record.Document)?.ToString();
|
||||
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name;
|
||||
}
|
||||
|
||||
public void DrawDocument(CultStoredDocument record)
|
||||
{
|
||||
DrawMembers(record.Document, record.Descriptor.DocumentType, record.Key.Value);
|
||||
}
|
||||
|
||||
public object DrawValue(string label, Type type, object value, MemberInfo member, string path)
|
||||
{
|
||||
if (Drawers.TryGetValue(type, out var drawer) || type.IsGenericType && Drawers.TryGetValue(type.GetGenericTypeDefinition(), out drawer))
|
||||
return drawer.Draw(label, value, member);
|
||||
|
||||
if (type == typeof(string)) return DrawString(label, value as string, member);
|
||||
var range = member?.GetCustomAttribute<CultInspectorRangeAttribute>();
|
||||
if (type == typeof(int))
|
||||
return range == null
|
||||
? EditorGUILayout.IntField(label, As<int>(value))
|
||||
: EditorGUILayout.IntSlider(label, As<int>(value), Mathf.RoundToInt(range.Min), Mathf.RoundToInt(range.Max));
|
||||
if (type == typeof(float))
|
||||
return range == null
|
||||
? EditorGUILayout.FloatField(label, As<float>(value))
|
||||
: EditorGUILayout.Slider(label, As<float>(value), range.Min, range.Max);
|
||||
if (type == typeof(bool)) return EditorGUILayout.Toggle(label, As<bool>(value));
|
||||
if (type == typeof(double)) return EditorGUILayout.DoubleField(label, As<double>(value));
|
||||
if (type == typeof(long)) return EditorGUILayout.LongField(label, As<long>(value));
|
||||
if (type == typeof(uint)) return (uint)Math.Min(uint.MaxValue, Math.Max(0L, EditorGUILayout.LongField(label, As<uint>(value))));
|
||||
if (type == typeof(short)) return (short)Mathf.Clamp(EditorGUILayout.IntField(label, As<short>(value)), short.MinValue, short.MaxValue);
|
||||
if (type == typeof(byte)) return (byte)Mathf.Clamp(EditorGUILayout.IntField(label, As<byte>(value)), 0, 255);
|
||||
if (type.IsEnum)
|
||||
{
|
||||
var current = value as Enum ?? (Enum)Enum.ToObject(type, 0);
|
||||
return type.IsDefined(typeof(FlagsAttribute), false)
|
||||
? EditorGUILayout.EnumFlagsField(label, current)
|
||||
: EditorGUILayout.EnumPopup(label, current);
|
||||
}
|
||||
|
||||
if (type == typeof(CM.float2)) { var v = As<CM.float2>(value); var r = EditorGUILayout.Vector2Field(label, new Vector2(v.x, v.y)); return new CM.float2(r.x, r.y); }
|
||||
if (type == typeof(CM.float3)) { var v = As<CM.float3>(value); var r = EditorGUILayout.Vector3Field(label, new Vector3(v.x, v.y, v.z)); return new CM.float3(r.x, r.y, r.z); }
|
||||
if (type == typeof(CM.float4)) { var v = As<CM.float4>(value); var r = EditorGUILayout.Vector4Field(label, new Vector4(v.x, v.y, v.z, v.w)); return new CM.float4(r.x, r.y, r.z, r.w); }
|
||||
if (type == typeof(CM.quaternion)) { var v = As<CM.quaternion>(value); var r = EditorGUILayout.Vector4Field(label, new Vector4(v.x, v.y, v.z, v.w)); return new CM.quaternion(r.x, r.y, r.z, r.w); }
|
||||
if (type == typeof(CM.int2)) { var v = As<CM.int2>(value); var r = EditorGUILayout.Vector2IntField(label, new Vector2Int(v.x, v.y)); return new CM.int2(r.x, r.y); }
|
||||
if (type == typeof(CM.double2)) { var v = As<CM.double2>(value); var r = DrawDoubles(label, v.x, v.y); return new CM.double2(r[0], r[1]); }
|
||||
if (type == typeof(CM.double3)) { var v = As<CM.double3>(value); var r = DrawDoubles(label, v.x, v.y, v.z); return new CM.double3(r[0], r[1], r[2]); }
|
||||
if (type == typeof(CM.bool2)) return DrawBool2(label, As<CM.bool2>(value));
|
||||
if (type == typeof(CM.rect))
|
||||
{
|
||||
var v = As<CM.rect>(value);
|
||||
var r = EditorGUILayout.RectField(label, Rect.MinMaxRect(v.min.x, v.min.y, v.max.x, v.max.y));
|
||||
return new CM.rect(r.xMin, r.yMin, r.xMax, r.yMax);
|
||||
}
|
||||
|
||||
if (type == typeof(CM.Color32))
|
||||
{
|
||||
var v = As<CM.Color32>(value);
|
||||
Color32 c = EditorGUILayout.ColorField(label, new Color32(v.r, v.g, v.b, v.a));
|
||||
return new CM.Color32(c.r, c.g, c.b, c.a);
|
||||
}
|
||||
|
||||
if (type == typeof(Vector2)) return EditorGUILayout.Vector2Field(label, As<Vector2>(value));
|
||||
if (type == typeof(Vector3)) return EditorGUILayout.Vector3Field(label, As<Vector3>(value));
|
||||
if (type == typeof(Color)) return EditorGUILayout.ColorField(label, value is Color color ? color : Color.white);
|
||||
if (typeof(Object).IsAssignableFrom(type)) return EditorGUILayout.ObjectField(label, value as Object, type, false);
|
||||
|
||||
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) return DrawRecordRef(label, type, value);
|
||||
var dictionary = DictionaryArguments(type);
|
||||
if (dictionary != null) return DrawDictionary(label, type, dictionary, value as IDictionary, member, path);
|
||||
var element = ListElement(type);
|
||||
if (element != null) return DrawList(label, type, element, value, member, path);
|
||||
if (type.IsAbstract || type.IsInterface) return DrawUnion(label, type, value, path);
|
||||
if (type.IsClass || type.IsValueType && !type.IsPrimitive) return DrawNested(label, type, value, path);
|
||||
return ErrorRow(label, "no drawer for " + type.FullName, value);
|
||||
}
|
||||
|
||||
private void DrawMembers(object target, Type type, string path)
|
||||
{
|
||||
foreach (var member in MembersOf(type))
|
||||
{
|
||||
if (member.GetCustomAttribute<CultInspectorHiddenAttribute>() != null) continue;
|
||||
var current = Get(member, target);
|
||||
var readOnly = !Writable(member) || member.GetCustomAttribute<CultInspectorReadOnlyAttribute>() != null;
|
||||
using (new EditorGUI.DisabledScope(readOnly))
|
||||
{
|
||||
EditorGUI.BeginChangeCheck();
|
||||
var next = DrawValue(LabelOf(member), TypeOf(member), current, member, path + "." + member.Name);
|
||||
if (EditorGUI.EndChangeCheck() && !readOnly) Set(member, target, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Document members come from the registry's catalog; nested members are the MessagePack-keyed ones,
|
||||
// or the public fields of an unkeyed struct.
|
||||
private MemberInfo[] MembersOf(Type type)
|
||||
{
|
||||
if (_members.TryGetValue(type, out var cached)) return cached;
|
||||
var candidates = type.GetMembers(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(m => m is FieldInfo || m is PropertyInfo p && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
.Where(m => m.GetCustomAttribute<IgnoreMemberAttribute>() == null)
|
||||
.ToArray();
|
||||
|
||||
IEnumerable<(MemberInfo Member, int Slot)> slotted;
|
||||
if (type.IsDefined(typeof(CultDocumentAttribute), false))
|
||||
{
|
||||
var slots = _cache.Registry.GetRequired(type).ToCatalogEntry().Members.ToDictionary(m => m.MemberName, m => m.Slot);
|
||||
slotted = candidates.Where(m => slots.ContainsKey(m.Name)).Select(m => (m, slots[m.Name]));
|
||||
}
|
||||
else if (candidates.Any(m => m.IsDefined(typeof(KeyAttribute), true)))
|
||||
{
|
||||
slotted = candidates.Where(m => m.IsDefined(typeof(KeyAttribute), true))
|
||||
.Select(m => (m, m.GetCustomAttribute<KeyAttribute>().IntKey ?? int.MaxValue));
|
||||
}
|
||||
else
|
||||
{
|
||||
slotted = type.IsValueType
|
||||
? candidates.OfType<FieldInfo>().Select(f => ((MemberInfo)f, f.MetadataToken))
|
||||
: Enumerable.Empty<(MemberInfo, int)>();
|
||||
}
|
||||
|
||||
cached = slotted
|
||||
.OrderBy(s => s.Member.GetCustomAttribute<CultInspectorOrderAttribute>()?.Order ?? s.Slot)
|
||||
.ThenBy(s => s.Slot)
|
||||
.Select(s => s.Member)
|
||||
.ToArray();
|
||||
_members[type] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
private static string DrawString(string label, string value, MemberInfo member)
|
||||
{
|
||||
var assetPath = member?.GetCustomAttribute<CultInspectorAssetPathAttribute>();
|
||||
if (assetPath != null)
|
||||
{
|
||||
var assetType = assetPath.AssetType != null && typeof(Object).IsAssignableFrom(assetPath.AssetType) ? assetPath.AssetType : typeof(Object);
|
||||
var asset = string.IsNullOrEmpty(value) ? null : AssetDatabase.LoadAssetAtPath(value, assetType);
|
||||
var next = EditorGUILayout.ObjectField(label, asset, assetType, false);
|
||||
return next == asset ? value : next == null ? string.Empty : AssetDatabase.GetAssetPath(next);
|
||||
}
|
||||
|
||||
var textArea = member?.GetCustomAttribute<CultInspectorTextAreaAttribute>();
|
||||
if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty);
|
||||
EditorGUILayout.LabelField(label);
|
||||
return EditorGUILayout.TextArea(value ?? string.Empty,
|
||||
GUILayout.MinHeight(Mathf.Max(textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight),
|
||||
GUILayout.MaxHeight(Mathf.Max(textArea.MaxLines, textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight));
|
||||
}
|
||||
|
||||
private object DrawRecordRef(string label, Type type, object value)
|
||||
{
|
||||
var target = type.GetGenericArguments()[0];
|
||||
var key = value == null ? string.Empty : ((CultRecordKey)type.GetProperty(nameof(CultRecordRef<object>.Key)).GetValue(value)).Value ?? string.Empty;
|
||||
var candidates = Records.Where(r => target.IsInstanceOfType(r.Document))
|
||||
.Select(r => (Record: r, Label: RecordLabel(r)))
|
||||
.OrderBy(c => c.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var index = Array.FindIndex(candidates, c => c.Record.Key.Value == key) + 1;
|
||||
var names = new string[candidates.Length + 1];
|
||||
names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None";
|
||||
for (var i = 0; i < candidates.Length; i++) names[i + 1] = candidates[i].Label;
|
||||
|
||||
var next = key;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
var picked = EditorGUILayout.Popup(label, index, names);
|
||||
var indent = EditorGUI.indentLevel;
|
||||
EditorGUI.indentLevel = 0;
|
||||
var typed = EditorGUILayout.DelayedTextField(key, GUILayout.Width(120));
|
||||
EditorGUI.indentLevel = indent;
|
||||
if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Record.Key.Value;
|
||||
else if (typed != key) next = typed;
|
||||
}
|
||||
|
||||
return next == key ? value : Activator.CreateInstance(type, new CultRecordKey(next));
|
||||
}
|
||||
|
||||
private object DrawDictionary(string label, Type type, Type[] arguments, IDictionary dictionary, MemberInfo member, string path)
|
||||
{
|
||||
var entries = new List<DictionaryEntry>();
|
||||
if (dictionary != null)
|
||||
{
|
||||
var enumerator = dictionary.GetEnumerator();
|
||||
while (enumerator.MoveNext()) entries.Add(enumerator.Entry);
|
||||
}
|
||||
|
||||
if (!Foldout(path, label + " (" + entries.Count + ")")) return dictionary;
|
||||
EditorGUI.indentLevel++;
|
||||
var changed = false;
|
||||
var remove = -1;
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope())
|
||||
{
|
||||
EditorGUI.BeginChangeCheck();
|
||||
var key = DrawValue("Key", arguments[0], entries[i].Key, null, path + "{" + i + "}.key");
|
||||
var item = DrawValue("Value", arguments[1], entries[i].Value, member, path + "{" + i + "}.value");
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
var index = i;
|
||||
if (key == null || entries.Where((_, j) => j != index).Any(e => Equals(e.Key, key))) key = entries[i].Key;
|
||||
entries[i] = new DictionaryEntry(key, item);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("-", GUILayout.Width(20))) remove = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (remove >= 0)
|
||||
{
|
||||
entries.RemoveAt(remove);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
var fresh = CreateDefault(arguments[0]);
|
||||
using (new EditorGUI.DisabledScope(fresh == null || entries.Any(e => Equals(e.Key, fresh))))
|
||||
{
|
||||
if (GUILayout.Button("Add"))
|
||||
{
|
||||
entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1])));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
if (!changed) return dictionary;
|
||||
var result = dictionary ?? (IDictionary)Activator.CreateInstance(type.IsInterface || type.IsAbstract
|
||||
? typeof(Dictionary<,>).MakeGenericType(arguments)
|
||||
: type);
|
||||
result.Clear();
|
||||
foreach (var entry in entries) result.Add(entry.Key, entry.Value);
|
||||
return result;
|
||||
}
|
||||
|
||||
private object DrawList(string label, Type type, Type element, object value, MemberInfo member, string path)
|
||||
{
|
||||
var items = new List<object>();
|
||||
if (value is IEnumerable enumerable)
|
||||
foreach (var item in enumerable) items.Add(item);
|
||||
|
||||
if (!Foldout(path, label + " [" + items.Count + "]")) return value;
|
||||
EditorGUI.indentLevel++;
|
||||
var changed = false;
|
||||
var remove = -1;
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope())
|
||||
{
|
||||
EditorGUI.BeginChangeCheck();
|
||||
var next = DrawValue("Element " + i, element, items[i], member, path + "[" + i + "]");
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
items[i] = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("-", GUILayout.Width(20))) remove = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (remove >= 0)
|
||||
{
|
||||
items.RemoveAt(remove);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (element.IsAbstract || element.IsInterface)
|
||||
{
|
||||
var unions = UnionsOf(element);
|
||||
var picked = EditorGUILayout.Popup("Add", 0, unions.Select(u => u.Name).Prepend("...").ToArray());
|
||||
if (picked > 0 && unions[picked - 1].GetConstructor(Type.EmptyTypes) != null)
|
||||
{
|
||||
items.Add(Activator.CreateInstance(unions[picked - 1]));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
else if (GUILayout.Button("Add"))
|
||||
{
|
||||
items.Add(CreateDefault(element));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
if (!changed) return value;
|
||||
if (type.IsArray)
|
||||
{
|
||||
var array = Array.CreateInstance(element, items.Count);
|
||||
for (var i = 0; i < items.Count; i++) array.SetValue(items[i], i);
|
||||
return array;
|
||||
}
|
||||
|
||||
var list = value as IList ?? (IList)Activator.CreateInstance(type);
|
||||
list.Clear();
|
||||
foreach (var item in items) list.Add(item);
|
||||
return list;
|
||||
}
|
||||
|
||||
private object DrawUnion(string label, Type type, object value, string path)
|
||||
{
|
||||
var unions = UnionsOf(type);
|
||||
if (unions.Length == 0) return ErrorRow(label, type.Name + " declares no [Union] subtypes", value);
|
||||
var index = value == null ? 0 : Array.IndexOf(unions, value.GetType()) + 1;
|
||||
var names = unions.Select(u => u.Name).Prepend(value == null ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
|
||||
|
||||
bool expanded;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
expanded = Foldout(path, label, GUILayout.Width(EditorGUIUtility.labelWidth));
|
||||
var indent = EditorGUI.indentLevel;
|
||||
EditorGUI.indentLevel = 0;
|
||||
var picked = EditorGUILayout.Popup(index, names);
|
||||
EditorGUI.indentLevel = indent;
|
||||
if (picked != index)
|
||||
{
|
||||
if (picked == 0) value = null;
|
||||
else if (unions[picked - 1].GetConstructor(Type.EmptyTypes) != null) value = Activator.CreateInstance(unions[picked - 1]);
|
||||
else Debug.LogError(unions[picked - 1].FullName + " has no parameterless constructor.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!expanded || value == null) return value;
|
||||
EditorGUI.indentLevel++;
|
||||
DrawMembers(value, value.GetType(), path);
|
||||
EditorGUI.indentLevel--;
|
||||
return value;
|
||||
}
|
||||
|
||||
private object DrawNested(string label, Type type, object value, string path)
|
||||
{
|
||||
if (MembersOf(type).Length == 0) return ErrorRow(label, "no drawer for " + type.FullName, value);
|
||||
if (value == null)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(label, "null");
|
||||
if (type.GetConstructor(Type.EmptyTypes) != null && GUILayout.Button("Create", GUILayout.Width(56)))
|
||||
value = Activator.CreateInstance(type);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!Foldout(path, label)) return value;
|
||||
EditorGUI.indentLevel++;
|
||||
DrawMembers(value, type, path);
|
||||
EditorGUI.indentLevel--;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static double[] DrawDoubles(string label, params double[] values)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.PrefixLabel(label);
|
||||
var indent = EditorGUI.indentLevel;
|
||||
EditorGUI.indentLevel = 0;
|
||||
for (var i = 0; i < values.Length; i++) values[i] = EditorGUILayout.DoubleField(values[i]);
|
||||
EditorGUI.indentLevel = indent;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static CM.bool2 DrawBool2(string label, CM.bool2 value)
|
||||
{
|
||||
bool x, y;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.PrefixLabel(label);
|
||||
var indent = EditorGUI.indentLevel;
|
||||
EditorGUI.indentLevel = 0;
|
||||
x = EditorGUILayout.ToggleLeft("X", value.x, GUILayout.Width(36));
|
||||
y = EditorGUILayout.ToggleLeft("Y", value.y, GUILayout.Width(36));
|
||||
EditorGUI.indentLevel = indent;
|
||||
}
|
||||
|
||||
return new CM.bool2(x, y);
|
||||
}
|
||||
|
||||
// Foldouts stay usable inside disabled scopes so read-only data can still be browsed.
|
||||
private bool Foldout(string path, string label, params GUILayoutOption[] options)
|
||||
{
|
||||
_foldouts.TryGetValue(path, out var expanded);
|
||||
var enabled = GUI.enabled;
|
||||
GUI.enabled = true;
|
||||
expanded = EditorGUI.Foldout(EditorGUILayout.GetControlRect(false, options), expanded, label, true);
|
||||
GUI.enabled = enabled;
|
||||
_foldouts[path] = expanded;
|
||||
return expanded;
|
||||
}
|
||||
|
||||
private static object ErrorRow(string label, string message, object value)
|
||||
{
|
||||
EditorGUILayout.LabelField(label, message, ErrorStyle);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Type[] UnionsOf(Type type) =>
|
||||
type.GetCustomAttributes<UnionAttribute>(false).OrderBy(u => u.Key).Select(u => u.SubType).ToArray();
|
||||
|
||||
private static Type[] DictionaryArguments(Type type) =>
|
||||
type.GetInterfaces().Prepend(type)
|
||||
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))
|
||||
?.GetGenericArguments();
|
||||
|
||||
private static Type ListElement(Type type)
|
||||
{
|
||||
if (type.IsArray) return type.GetElementType();
|
||||
return type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type)
|
||||
? type.GetGenericArguments()[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static object CreateDefault(Type type)
|
||||
{
|
||||
if (type == typeof(string)) return string.Empty;
|
||||
if (type.IsValueType) return Activator.CreateInstance(type);
|
||||
if (type.IsArray) return Array.CreateInstance(type.GetElementType(), 0);
|
||||
return type.IsAbstract || type.IsInterface || type.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(type);
|
||||
}
|
||||
|
||||
private static T As<T>(object value) => value is T typed ? typed : default;
|
||||
|
||||
private static string LabelOf(MemberInfo member)
|
||||
{
|
||||
var label = member.GetCustomAttribute<CultInspectorLabelAttribute>();
|
||||
return label == null || string.IsNullOrWhiteSpace(label.Label) ? ObjectNames.NicifyVariableName(member.Name) : label.Label;
|
||||
}
|
||||
|
||||
private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType;
|
||||
|
||||
private static object Get(MemberInfo member, object target) => member is FieldInfo field ? field.GetValue(target) : ((PropertyInfo)member).GetValue(target);
|
||||
|
||||
private static void Set(MemberInfo member, object target, object value)
|
||||
{
|
||||
if (member is FieldInfo field) field.SetValue(target, value);
|
||||
else ((PropertyInfo)member).SetValue(target, value);
|
||||
}
|
||||
|
||||
private static bool Writable(MemberInfo member) =>
|
||||
member is FieldInfo field ? !field.IsInitOnly && !field.IsLiteral : ((PropertyInfo)member).GetSetMethod() != null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 06d55de1c539404b9a935871a8a3a334
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,8 @@
|
|||
"name": "GameCult.Unity.Caching.Editor",
|
||||
"rootNamespace": "GameCult.Unity.Caching.Editor",
|
||||
"references": [
|
||||
"GameCult.Unity.Caching"
|
||||
"GameCult.Unity.Caching",
|
||||
"GameCult.CultLib"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
|
|
|
|||
|
|
@ -1,35 +1,77 @@
|
|||
# GameCult CultCache Unity Tools
|
||||
|
||||
`org.gamecult.caching.unity` provides Unity editor tooling for opening,
|
||||
inspecting, editing, and saving CultCache `.cc` stores.
|
||||
`org.gamecult.caching.unity` is the Unity editor surface over CultCache: open a
|
||||
store, browse its documents, edit them, save.
|
||||
|
||||
The package is intentionally a Unity editor surface over `GameCult.Caching`.
|
||||
CultCache owns document identity, schema descriptors, persistence, dirty state,
|
||||
and flush behavior. The editor window owns selection, presentation, and edit
|
||||
commands.
|
||||
CultCache owns document identity, schema descriptors, persistence, dirty state
|
||||
and flush. The Studio window owns selection, presentation and edit commands. It
|
||||
references `GameCult.Caching` and `GameCult.Caching.MessagePack` at compile time
|
||||
through the `org.gamecult.cultlib` package (assembly `GameCult.CultLib`) and
|
||||
`CultMath` through `org.gamecult.cultmath`.
|
||||
|
||||
## Use
|
||||
|
||||
1. Reference `GameCult.Caching`, `GameCult.Caching.MessagePack`, and this UPM
|
||||
package from the Unity project that declares your `[CultDocument]` classes.
|
||||
2. Open Unity's `GameCult/CultCache Studio` menu.
|
||||
3. Open or create a `.cc` file.
|
||||
4. Select a document type, select a record, edit fields, then save explicitly.
|
||||
1. Add this package; it depends on `org.gamecult.cultlib` and
|
||||
`org.gamecult.cultmath`.
|
||||
2. Open `GameCult/CultCache Studio`.
|
||||
3. `Open` a `.cc` store or make one with `New`. `Directory` opens or creates a
|
||||
directory store (a manifest beside its `.records` folder; an existing one is
|
||||
detected without the toggle). `Read Only` opens the store read-only.
|
||||
4. Pick a document type, then a record. Edits upsert into the cache as you make
|
||||
them; the toolbar shows `Dirty` until `Save` flushes the store.
|
||||
|
||||
The editor discovers registered CultCache document descriptors from the same
|
||||
registry used by runtime code. It does not require document classes to inherit
|
||||
from Unity types or storage base classes.
|
||||
Document types come from the cache's `CultDocumentRegistry`. A type lists its
|
||||
own records and those of its subclasses, by `[CultName]` with the key as
|
||||
fallback. `Add`, `Duplicate` and `Delete` act on the selected type and record.
|
||||
A `[CultGlobal]` type with no record shows as absent with a `Create` action; the
|
||||
Studio never creates one on its own.
|
||||
|
||||
Opening a store never writes it. Closing or reloading scripts drops unsaved
|
||||
edits.
|
||||
|
||||
## Drawers
|
||||
|
||||
Built in: strings, integers, floats, bools, enums (flags included), the CultMath
|
||||
values (`float2/3/4`, `double2/3`, `int2`, `bool2`, `quaternion`, `rect`,
|
||||
`Color32`), `Vector2/3`, `Color`, Unity object
|
||||
references, lists and arrays, dictionaries (keys and values drawn recursively),
|
||||
`CultRecordRef<T>` (a picker of records assignable to `T`, plus the raw key),
|
||||
abstract and interface members (a subtype picker limited to the member type's
|
||||
MessagePack `[Union]` declarations), and nested `[MessagePackObject]` types by
|
||||
their `[Key]` members. A member no drawer claims shows a red error row.
|
||||
|
||||
A project adds or overrides a drawer with an editor class:
|
||||
|
||||
```csharp
|
||||
using System.Reflection;
|
||||
using GameCult.Unity.Caching;
|
||||
using GameCult.Unity.Caching.Editor;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
[CultInspectorDrawer(typeof(CultMath.float3))]
|
||||
public sealed class Float3ColorDrawer : ICultInspectorDrawer
|
||||
{
|
||||
public object Draw(string label, object value, MemberInfo member)
|
||||
{
|
||||
var v = (CultMath.float3)value;
|
||||
var c = EditorGUILayout.ColorField(label, new Color(v.x, v.y, v.z));
|
||||
return new CultMath.float3(c.r, c.g, c.b);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`MemberType` may be an open generic definition to claim every closed form. A
|
||||
project drawer wins over the built-in one for its type.
|
||||
|
||||
## Inspector Annotations
|
||||
|
||||
Domain classes can opt into nicer Unity editor rendering with attributes from
|
||||
`GameCult.Unity.Caching`:
|
||||
|
||||
```csharp
|
||||
using GameCult.Caching;
|
||||
using GameCult.Unity.Caching;
|
||||
using MessagePack;
|
||||
|
||||
[MessagePackObject]
|
||||
[CultDocument("game.item", "game.item.v1")]
|
||||
public sealed class ItemData
|
||||
{
|
||||
|
|
@ -52,8 +94,6 @@ public sealed class ItemData
|
|||
}
|
||||
```
|
||||
|
||||
Available annotations:
|
||||
|
||||
- `CultInspectorLabel`
|
||||
- `CultInspectorHidden`
|
||||
- `CultInspectorReadOnly`
|
||||
|
|
@ -61,13 +101,7 @@ Available annotations:
|
|||
- `CultInspectorTextArea`
|
||||
- `CultInspectorRange`
|
||||
- `CultInspectorAssetPath`
|
||||
|
||||
## Current Scope
|
||||
|
||||
The first studio pass supports local `.cc` files, explicit save/reload, adding
|
||||
records with parameterless constructors, deleting records, primitive fields,
|
||||
enums, strings, lists/arrays, nested objects, Unity object references, asset
|
||||
path pickers, and `CultRecordRef<T>` key editing.
|
||||
- `CultInspectorDrawer` (on drawer classes)
|
||||
|
||||
CultMesh collaboration should feed the same CultCache mutation surface rather
|
||||
than becoming a second owner for document state.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@ using System;
|
|||
|
||||
namespace GameCult.Unity.Caching
|
||||
{
|
||||
/// <summary>
|
||||
/// Overrides the label shown for a CultCache document member in Unity editor tooling.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = true)]
|
||||
public sealed class CultInspectorLabelAttribute : Attribute
|
||||
{
|
||||
|
|
@ -16,25 +13,17 @@ namespace GameCult.Unity.Caching
|
|||
public string Label { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides a CultCache document member from Unity editor tooling.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorHiddenAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a CultCache document member without allowing editor mutation.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorReadOnlyAttribute : Attribute
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controls member ordering in Unity editor tooling. Lower values appear first.
|
||||
/// </summary>
|
||||
// Lower values appear first; members without it keep their key order.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorOrderAttribute : Attribute
|
||||
{
|
||||
|
|
@ -46,9 +35,6 @@ namespace GameCult.Unity.Caching
|
|||
public int Order { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws a string member as a multi-line text area.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorTextAreaAttribute : Attribute
|
||||
{
|
||||
|
|
@ -62,9 +48,6 @@ namespace GameCult.Unity.Caching
|
|||
public int MaxLines { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws numeric members with a slider.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorRangeAttribute : Attribute
|
||||
{
|
||||
|
|
@ -78,9 +61,6 @@ namespace GameCult.Unity.Caching
|
|||
public float Max { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws a string member as a Unity asset path picker.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultInspectorAssetPathAttribute : Attribute
|
||||
{
|
||||
|
|
@ -91,4 +71,17 @@ namespace GameCult.Unity.Caching
|
|||
|
||||
public Type AssetType { get; }
|
||||
}
|
||||
|
||||
// Marks an editor class implementing ICultInspectorDrawer as the drawer for every member of MemberType,
|
||||
// or of any closed form when MemberType is an open generic definition. It wins over the built-in drawers.
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class CultInspectorDrawerAttribute : Attribute
|
||||
{
|
||||
public CultInspectorDrawerAttribute(Type memberType)
|
||||
{
|
||||
MemberType = memberType ?? throw new ArgumentNullException(nameof(memberType));
|
||||
}
|
||||
|
||||
public Type MemberType { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.caching.unity",
|
||||
"displayName": "GameCult CultCache Unity Tools",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"unity": "2021.3",
|
||||
"description": "Unity editor tooling for inspecting and editing CultCache .cc stores.",
|
||||
"keywords": [
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
"Editor",
|
||||
"Data"
|
||||
],
|
||||
"dependencies": {
|
||||
"org.gamecult.cultlib": "1.0.57",
|
||||
"org.gamecult.cultmath": "0.1.2"
|
||||
},
|
||||
"license": "MIT",
|
||||
"category": "Scripting"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue