Lower the CultCache Studio from the inspection model

CultCache Studio 1.1.0 -> 1.2.0.

The IMGUI inspector is now a lowering of CultInspectorModel: it asks the
model for members, shapes, claims, record candidates and key rules and
hands every change back through it (BuildList, BuildDictionary, Compose,
CreateUnionValue, CreateRecordRef). Deleted from the Studio: its own member
discovery, list/dictionary/union classification, default construction,
key identity, the per-type CultMath drawers (composite shapes replace
them) and the nameof GetProperty key lookup. The window edits through
CultInspectorEdit and clones through the model.

Drawer extension point: ICultInspectorDrawer.Draw receives the inspector,
label, declared value type, value and member (also for list elements and
dictionary keys and values). Attribute claims win over type claims, which
win over built-in drawing; CultInspector.DrawDefault hands a value back to
built-in drawing and DrawValue draws a sub-value with claims applied.

The GameCult.Unity.Caching runtime assembly is deleted; its attributes
live in GameCult.Caching. The package no longer depends on
org.gamecult.cultmath.

Game data stays out of Assets: New refuses a path under the project's
Assets folder and says why, Open warns, and the file dialogs start in the
last store's folder or the project root, never Assets.

README names the model/lowering seam and the new drawer contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 13:43:45 +02:00
commit 4716c304ec
5 changed files with 358 additions and 420 deletions

View file

@ -4,109 +4,118 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
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.
// An IMGUI drawer claimed through [CultInspectorDrawer]. Returns the value's next value; a reference value may be
// mutated in place (it is always the edit's private copy). type is the value's declared type. member is the member
// the value belongs to, also for list elements and dictionary keys and values, and null for a bare value.
// inspector.DrawDefault hands the value back to built-in drawing; inspector.DrawValue draws a sub-value with claims.
public interface ICultInspectorDrawer
{
object Draw(string label, object value, MemberInfo member);
object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member);
}
internal sealed class CultInspector
// The IMGUI lowering of CultInspectorModel. The model decides members, metadata, shapes, drawer claims and edit
// rules; this class draws them and hands every change back through the model.
public sealed class CultInspector
{
private static Dictionary<Type, ICultInspectorDrawer> _drawers;
private static Dictionary<Type, string> _conflicts;
private static readonly Dictionary<Type, MemberInfo> NameMembers = new Dictionary<Type, MemberInfo>();
private static CultInspectorDrawerClaims _claims;
private static readonly Dictionary<Type, ICultInspectorDrawer> DrawerInstances = new Dictionary<Type, ICultInspectorDrawer>();
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[]>();
private readonly Dictionary<string, string> _notices = new Dictionary<string, string>(StringComparer.Ordinal);
private string _path = string.Empty;
public CultInspector(CultCache cache)
internal CultInspector(CultInspectorModel model)
{
_cache = cache;
Model = model;
}
public CultStoredDocument[] Records { get; set; } = Array.Empty<CultStoredDocument>();
public CultInspectorModel Model { get; }
public IReadOnlyList<CultStoredDocument> Records { get; internal 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
private static CultInspectorDrawerClaims Claims
{
get
{
if (_drawers != null) return _drawers;
_drawers = new Dictionary<Type, ICultInspectorDrawer>();
_conflicts = new Dictionary<Type, string>();
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.");
continue;
}
// A contested type is claimed by nobody: it draws the red row until one claimant is removed.
if (_drawers.TryGetValue(memberType, out var claimed))
{
_drawers.Remove(memberType);
_conflicts[memberType] = claimed.GetType().FullName;
}
if (_conflicts.TryGetValue(memberType, out var claimants))
{
_conflicts[memberType] = claimants + ", " + type.FullName;
Debug.LogError($"{_conflicts[memberType]} all draw {memberType.FullName}; none of them is used.");
}
else
{
_drawers[memberType] = (ICultInspectorDrawer)Activator.CreateInstance(type);
}
}
return _drawers;
if (_claims != null) return _claims;
_claims = new CultInspectorDrawerClaims(TypeCache.GetTypesWithAttribute<CultInspectorDrawerAttribute>(), typeof(ICultInspectorDrawer));
foreach (var error in _claims.Errors) Debug.LogError(error);
return _claims;
}
}
public static string RecordLabel(CultStoredDocument record)
internal void DrawDocument(CultInspectorEdit edit)
{
var type = record.Descriptor.DocumentType;
if (!NameMembers.TryGetValue(type, out var member))
DrawMembers(edit.Document, edit.Source.Descriptor.DocumentType, edit.Source.Key.Value);
}
// Draws a sub-value of the value being drawn, claims applied.
public object DrawValue(string label, Type type, object value, MemberInfo member)
{
return DrawValue(label, type, value, member, _path + "/" + label);
}
// Built-in drawing for a value, ignoring drawer claims on it.
public object DrawDefault(string label, Type type, object value, MemberInfo member)
{
var shape = Model.ShapeOf(type);
var metadata = Model.MetadataOf(member);
switch (shape.Kind)
{
member = record.Descriptor.NameMember == null
? null
: type.GetMember(record.Descriptor.NameMember, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault();
NameMembers[type] = member;
case CultInspectorValueKind.String:
return DrawString(label, value as string, metadata);
case CultInspectorValueKind.Integer:
return DrawInteger(label, type, value, metadata);
case CultInspectorValueKind.Float:
if (type == typeof(double)) return EditorGUILayout.DoubleField(label, value is double d ? d : 0d);
var f = value is float single ? single : 0f;
return metadata.Range == null ? EditorGUILayout.FloatField(label, f) : EditorGUILayout.Slider(label, f, metadata.Range.Min, metadata.Range.Max);
case CultInspectorValueKind.Bool:
return EditorGUILayout.Toggle(label, value is bool b && b);
case CultInspectorValueKind.Enum:
var current = value as Enum ?? (Enum)Enum.ToObject(type, 0);
return type.IsDefined(typeof(FlagsAttribute), false) ? EditorGUILayout.EnumFlagsField(label, current) : EditorGUILayout.EnumPopup(label, current);
case CultInspectorValueKind.Composite:
return DrawComposite(label, shape, value);
case CultInspectorValueKind.RecordRef:
return DrawRecordRef(label, type, value);
case CultInspectorValueKind.List:
return DrawList(label, shape, value, member);
case CultInspectorValueKind.Dictionary:
return DrawDictionary(label, shape, value as IDictionary, member);
case CultInspectorValueKind.Union:
return DrawUnion(label, shape, value);
case CultInspectorValueKind.Nested:
return DrawNested(label, type, value);
default:
return typeof(Object).IsAssignableFrom(type)
? EditorGUILayout.ObjectField(label, value as Object, type, false)
: ErrorRow(label, shape.Reason, value);
}
var name = member == null ? null : Get(member, record.Document)?.ToString();
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name;
}
// Draws into the document it is given and mutates it in place; the caller hands over a working copy.
public void DrawDocument(object document, Type type, string key)
private object DrawValue(string label, Type type, object value, MemberInfo member, string path)
{
DrawMembers(document, type, key);
}
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))
var outer = _path;
_path = path;
try
{
var claim = Claims.Resolve(type, member);
if (claim.Conflict != null) return ErrorRow(label, "no drawer: " + claim.Conflict, value);
if (claim.Drawer == null) return DrawDefault(label, type, value, member);
var changed = GUI.changed;
try
{
return drawer.Draw(label, value, member);
return Drawer(claim.Drawer).Draw(this, label, type, value, member);
}
catch (ExitGUIException)
{
@ -115,141 +124,47 @@ namespace GameCult.Unity.Caching.Editor
catch (Exception exception)
{
GUI.changed = changed;
return ErrorRow(label, drawer.GetType().Name + " failed: " + exception.Message, value);
return ErrorRow(label, claim.Drawer.Name + " failed: " + exception.Message, value);
}
}
if (_conflicts.TryGetValue(type, out var claimants) || type.IsGenericType && _conflicts.TryGetValue(type.GetGenericTypeDefinition(), out claimants))
return ErrorRow(label, "no drawer for " + type.FullName + " (contested by " + claimants + ")", value);
if (type.IsArray && type.GetArrayRank() != 1)
return ErrorRow(label, type.Name + " is a multi-dimensional array; the Studio cannot edit it", value);
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)
finally
{
var current = value as Enum ?? (Enum)Enum.ToObject(type, 0);
return type.IsDefined(typeof(FlagsAttribute), false)
? EditorGUILayout.EnumFlagsField(label, current)
: EditorGUILayout.EnumPopup(label, current);
_path = outer;
}
}
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 static ICultInspectorDrawer Drawer(Type type)
{
if (!DrawerInstances.TryGetValue(type, out var drawer))
DrawerInstances[type] = drawer = (ICultInspectorDrawer)Activator.CreateInstance(type);
return drawer;
}
private void DrawMembers(object target, Type type, string path)
{
foreach (var member in MembersOf(type))
foreach (var member in Model.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))
if (member.Metadata.Hidden) continue;
using (new EditorGUI.DisabledScope(member.IsReadOnly))
{
EditorGUI.BeginChangeCheck();
var next = DrawValue(LabelOf(member), TypeOf(member), current, member, path + "." + member.Name);
if (EditorGUI.EndChangeCheck() && !readOnly) Set(member, target, next);
var next = DrawValue(LabelOf(member), member.ValueType, member.GetValue(target), member.Member, path + "." + member.Name);
if (EditorGUI.EndChangeCheck() && !member.IsReadOnly) member.SetValue(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)
private static string DrawString(string label, string value, CultInspectorMetadata metadata)
{
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))
if (metadata.AssetPath != null)
{
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 assetType = metadata.AssetPath.AssetType != null && typeof(Object).IsAssignableFrom(metadata.AssetPath.AssetType) ? metadata.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>();
var textArea = metadata.TextArea;
if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty);
EditorGUILayout.LabelField(label);
return EditorGUILayout.TextArea(value ?? string.Empty,
@ -257,19 +172,85 @@ namespace GameCult.Unity.Caching.Editor
GUILayout.MaxHeight(Mathf.Max(textArea.MaxLines, textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight));
}
private static object DrawInteger(string label, Type type, object value, CultInspectorMetadata metadata)
{
var current = value == null ? 0L : Convert.ToInt64(value);
if (type == typeof(int) && metadata.Range != null)
return EditorGUILayout.IntSlider(label, (int)current, Mathf.RoundToInt(metadata.Range.Min), Mathf.RoundToInt(metadata.Range.Max));
var next = EditorGUILayout.LongField(label, current);
if (next == current) return value ?? Activator.CreateInstance(type);
try
{
return Convert.ChangeType(next, type);
}
catch (OverflowException)
{
return value ?? Activator.CreateInstance(type);
}
}
// Scalar components draw on one row; anything else folds out. A change composes a new value.
private object DrawComposite(string label, CultInspectorShape shape, object value)
{
var path = _path;
value = value ?? Model.CreateDefault(shape.Type);
var components = shape.Members;
var values = components.Select(component => component.GetValue(value)).ToArray();
var changed = false;
if (components.All(component => Model.ShapeOf(component.ValueType).Kind is CultInspectorValueKind.Float or CultInspectorValueKind.Integer or CultInspectorValueKind.Bool))
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.PrefixLabel(label);
var indent = EditorGUI.indentLevel;
var labelWidth = EditorGUIUtility.labelWidth;
EditorGUI.indentLevel = 0;
EditorGUIUtility.labelWidth = 8 + 7 * components.Max(component => component.Name.Length);
for (var i = 0; i < components.Count; i++)
{
EditorGUI.BeginChangeCheck();
var next = DrawDefault(components[i].Name, components[i].ValueType, values[i], null);
if (!EditorGUI.EndChangeCheck()) continue;
values[i] = next;
changed = true;
}
EditorGUIUtility.labelWidth = labelWidth;
EditorGUI.indentLevel = indent;
}
}
else
{
if (!Foldout(path, label)) return value;
EditorGUI.indentLevel++;
for (var i = 0; i < components.Count; i++)
{
EditorGUI.BeginChangeCheck();
var next = DrawValue(LabelOf(components[i]), components[i].ValueType, values[i], components[i].Member, path + "." + components[i].Name);
if (!EditorGUI.EndChangeCheck()) continue;
values[i] = next;
changed = true;
}
EditorGUI.indentLevel--;
}
return changed ? Model.Compose(shape, values) : value;
}
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 key = CultInspectorModel.RecordKey(value);
var candidates = Model.RecordCandidates(type, Records);
var index = 0;
var names = new string[candidates.Count + 1];
for (var i = 0; i < candidates.Count; i++)
{
names[i + 1] = CultInspectorModel.RecordLabel(candidates[i]);
if (candidates[i].Key.Value == key) index = i + 1;
}
names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None";
var next = key;
using (new EditorGUILayout.HorizontalScope())
{
@ -278,20 +259,21 @@ namespace GameCult.Unity.Caching.Editor
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;
if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Key.Value;
else if (typed != key) next = typed;
}
return next == key ? value : Activator.CreateInstance(type, new CultRecordKey(next));
return next == key ? value : Model.CreateRecordRef(type, next);
}
private object DrawDictionary(string label, Type type, Type[] arguments, IDictionary dictionary, MemberInfo member, string path)
private object DrawDictionary(string label, CultInspectorShape shape, IDictionary dictionary, MemberInfo member)
{
var entries = new List<DictionaryEntry>();
var path = _path;
var entries = new List<KeyValuePair<object, object>>();
if (dictionary != null)
{
var enumerator = dictionary.GetEnumerator();
while (enumerator.MoveNext()) entries.Add(enumerator.Entry);
while (enumerator.MoveNext()) entries.Add(new KeyValuePair<object, object>(enumerator.Key, enumerator.Value));
}
if (!Foldout(path, label + " (" + entries.Count + ")")) return dictionary;
@ -305,30 +287,23 @@ namespace GameCult.Unity.Caching.Editor
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");
var key = DrawValue("Key", shape.KeyType, entries[i].Key, member, path + "{" + i + "}.key");
var item = DrawValue("Value", shape.ValueType, entries[i].Value, member, path + "{" + i + "}.value");
if (EditorGUI.EndChangeCheck())
{
var index = i;
var identity = KeyIdentity(key);
if (identity != KeyIdentity(entries[i].Key))
var refusal = Model.RefuseKey(key, entries[i].Key, entries.Where((_, j) => j != index).Select(e => e.Key));
if (refusal != null)
{
var refusal = identity == null ? "a null key"
: identity == EmptyRefIdentity ? "an empty record reference as a key"
: entries.Where((_, j) => j != index).Any(e => KeyIdentity(e.Key) == identity) ? "a duplicate key"
: null;
if (refusal != null)
{
_notices[path] = "Refused " + refusal + " on entry " + i + "; its key was kept.";
key = entries[i].Key;
}
else
{
_notices.Remove(path);
}
_notices[path] = "Refused " + refusal + " on entry " + i + "; its key was kept.";
key = entries[i].Key;
}
else
{
_notices.Remove(path);
}
entries[i] = new DictionaryEntry(key, item);
entries[i] = new KeyValuePair<object, object>(key, item);
changed = true;
}
}
@ -345,14 +320,14 @@ namespace GameCult.Unity.Caching.Editor
if (GUILayout.Button("Add"))
{
var fresh = FreshKey(arguments[0], entries);
var fresh = Model.FreshKey(shape.KeyType, entries.Select(e => e.Key), Records);
if (fresh == null)
{
_notices[path] = "No unused " + arguments[0].Name + " key is available; nothing was added.";
_notices[path] = "No unused " + shape.KeyType.Name + " key is available; nothing was added.";
}
else
{
entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1])));
entries.Add(new KeyValuePair<object, object>(fresh, Model.CreateDefault(shape.ValueType)));
_notices.Remove(path);
changed = true;
}
@ -360,50 +335,13 @@ namespace GameCult.Unity.Caching.Editor
if (_notices.TryGetValue(path, out var notice)) EditorGUILayout.HelpBox(notice, MessageType.Warning);
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;
return changed ? Model.BuildDictionary(shape.Type, entries) : dictionary;
}
private const string EmptyRefIdentity = "ref:";
// Keys are the same key when they serialize the same; a CultRecordRef<T> is its key string, null and "" alike.
private static string KeyIdentity(object key)
{
if (key == null) return null;
var type = key.GetType();
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) return EmptyRefIdentity + key;
try
{
return Convert.ToBase64String(MessagePackSerializer.Serialize(type, key, CultDocumentMessagePackSerialization.Options));
}
catch (Exception)
{
return type.FullName + ":" + key;
}
}
// A record-reference key takes the first record of its type not already used; nothing else invents a key.
private object FreshKey(Type type, List<DictionaryEntry> entries)
{
var used = new HashSet<string>(entries.Select(e => KeyIdentity(e.Key)), StringComparer.Ordinal);
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
{
var target = type.GetGenericArguments()[0];
var record = Records.FirstOrDefault(r => target.IsInstanceOfType(r.Document) && r.Key.Value.Length > 0 && !used.Contains(EmptyRefIdentity + r.Key.Value));
return record == null ? null : Activator.CreateInstance(type, new CultRecordKey(record.Key.Value));
}
var fresh = CreateDefault(type);
return fresh == null || used.Contains(KeyIdentity(fresh)) ? null : fresh;
}
private object DrawList(string label, Type type, Type element, object value, MemberInfo member, string path)
private object DrawList(string label, CultInspectorShape shape, object value, MemberInfo member)
{
var path = _path;
var element = shape.ElementType;
var items = new List<object>();
if (value is IEnumerable enumerable)
foreach (var item in enumerable) items.Add(item);
@ -437,43 +375,39 @@ namespace GameCult.Unity.Caching.Editor
changed = true;
}
if (element.IsAbstract || element.IsInterface)
var elementShape = Model.ShapeOf(element);
if (elementShape.Kind == CultInspectorValueKind.Union)
{
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)
var picked = EditorGUILayout.Popup("Add", 0, elementShape.UnionChoices.Select(u => u.Name).Prepend("...").ToArray());
if (picked > 0)
{
items.Add(Activator.CreateInstance(unions[picked - 1]));
changed = true;
try
{
items.Add(Model.CreateUnionValue(element, elementShape.UnionChoices[picked - 1]));
changed = true;
}
catch (InvalidOperationException exception)
{
Debug.LogError(exception.Message);
}
}
}
else if (GUILayout.Button("Add"))
{
items.Add(CreateDefault(element));
items.Add(Model.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;
return changed ? Model.BuildList(shape.Type, items) : value;
}
private object DrawUnion(string label, Type type, object value, string path)
private object DrawUnion(string label, CultInspectorShape shape, object value)
{
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 || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
var path = _path;
var choices = shape.UnionChoices;
var index = value == null ? 0 : IndexOf(choices, value.GetType()) + 1;
var names = choices.Select(u => u.Name).Prepend(value == null || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray();
bool expanded;
using (new EditorGUILayout.HorizontalScope())
@ -485,9 +419,14 @@ namespace GameCult.Unity.Caching.Editor
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.");
try
{
value = picked == 0 ? null : Model.CreateUnionValue(shape.Type, choices[picked - 1]);
}
catch (InvalidOperationException exception)
{
Debug.LogError(exception.Message);
}
}
}
@ -498,16 +437,16 @@ namespace GameCult.Unity.Caching.Editor
return value;
}
private object DrawNested(string label, Type type, object value, string path)
private object DrawNested(string label, Type type, object value)
{
if (MembersOf(type).Length == 0) return ErrorRow(label, "no drawer for " + type.FullName, value);
var path = _path;
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);
var created = Model.CreateDefault(type);
if (created != null && GUILayout.Button("Create", GUILayout.Width(56))) value = created;
}
return value;
@ -520,36 +459,6 @@ namespace GameCult.Unity.Caching.Editor
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. Expanding is view state:
// it never counts as a change, so it never upserts.
private bool Foldout(string path, string label, params GUILayoutOption[] options)
@ -571,49 +480,13 @@ namespace GameCult.Unity.Caching.Editor
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)
private static int IndexOf(IReadOnlyList<Type> types, Type type)
{
if (type.IsArray) return type.GetArrayRank() == 1 ? type.GetElementType() : null;
return type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type)
? type.GetGenericArguments()[0]
: null;
for (var i = 0; i < types.Count; i++)
if (types[i] == type) return i;
return -1;
}
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;
private static string LabelOf(CultInspectorMember member) => member.Metadata.Label ?? ObjectNames.NicifyVariableName(member.Name);
}
}

View file

@ -13,7 +13,9 @@ namespace GameCult.Unity.Caching.Editor
private const string LastPathKey = "GameCult.CultCacheStudio.LastPath";
private CultCache _cache;
private CultInspectorModel _model;
private CultInspector _inspector;
private CultInspectorEdit _edit;
private CultStoredDocument[] _records = Array.Empty<CultStoredDocument>();
private string _path = string.Empty;
private bool _directory;
@ -21,8 +23,6 @@ namespace GameCult.Unity.Caching.Editor
private string _search = string.Empty;
private Type _selectedType;
private string _selectedKey;
private CultStoredDocument _editSource;
private object _editCopy;
private Vector2 _typeScroll;
private Vector2 _recordScroll;
private Vector2 _inspectorScroll;
@ -31,6 +31,8 @@ namespace GameCult.Unity.Caching.Editor
private bool ReadOnly => _cache.BackingStores.Any(store => store.IsReadOnly);
private static string ProjectRoot => Path.GetDirectoryName(Path.GetFullPath(Application.dataPath));
[MenuItem("GameCult/CultCache Studio")]
public static void Open()
{
@ -80,6 +82,7 @@ namespace GameCult.Unity.Caching.Editor
EditorStyles.toolbarButton, GUILayout.Width(40)))
Browse(true);
}
using (new EditorGUI.DisabledScope(_cache == null))
{
if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(52)) && ConfirmDiscard()) OpenStore(_path, false);
@ -163,7 +166,7 @@ namespace GameCult.Unity.Caching.Editor
else
{
var records = _records.Where(r => _selectedType.IsAssignableFrom(r.Descriptor.DocumentType))
.Select(r => (Record: r, Label: CultInspector.RecordLabel(r)))
.Select(r => (Record: r, Label: CultInspectorModel.RecordLabel(r)))
.OrderBy(r => r.Label, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (descriptor.IsGlobal && records.Length == 0)
@ -203,26 +206,17 @@ namespace GameCult.Unity.Caching.Editor
EditorGUILayout.LabelField(record.Descriptor.SchemaName + " " + record.Descriptor.DocumentType.Name, EditorStyles.boldLabel);
EditorGUILayout.SelectableLabel("Key " + record.Key.Value + " Stored " + record.StoredAt, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight));
_inspectorScroll = EditorGUILayout.BeginScrollView(_inspectorScroll);
// Drawers edit a copy. The cached object changes only through an admitted upsert; after any upsert
// attempt the copy is dropped and re-cloned from whatever the cache now holds.
var type = record.Descriptor.DocumentType;
if (!ReferenceEquals(_editSource, record))
{
_editSource = record;
_editCopy = Clone(record.Document, type);
}
// Drawers edit the edit's private copy. A commit spends the edit, admitted or refused, and the next frame
// begins a new one from whatever record the cache then holds.
if (_edit == null || !_edit.IsFor(record)) _edit = _model.BeginEdit(record);
var readOnly = ReadOnly;
using (new EditorGUI.DisabledScope(readOnly))
{
EditorGUI.BeginChangeCheck();
_inspector.DrawDocument(_editCopy, type, record.Key.Value);
if (EditorGUI.EndChangeCheck() && !readOnly)
{
var edited = _editCopy;
_editSource = null;
Run(null, () => _cache.UpsertAsync(type, edited, record.Key).GetAwaiter().GetResult());
}
_inspector.DrawDocument(_edit);
if (EditorGUI.EndChangeCheck() && !readOnly && !_edit.Commit(_cache, out var error))
SetStatus(error, MessageType.Error);
}
EditorGUILayout.EndScrollView();
@ -234,10 +228,12 @@ namespace GameCult.Unity.Caching.Editor
return _selectedKey == null ? null : _records.FirstOrDefault(r => r.Key.Value == _selectedKey);
}
// Dialogs start in the last store's folder, or the project root; never under Assets.
private void Browse(bool create)
{
if (!ConfirmDiscard()) return;
var directory = string.IsNullOrEmpty(_path) ? Application.dataPath : Path.GetDirectoryName(_path);
var last = string.IsNullOrEmpty(_path) ? null : Path.GetDirectoryName(FullPathOrNull(_path) ?? string.Empty);
var directory = !string.IsNullOrEmpty(last) && Directory.Exists(last) && !InsideAssets(last) ? last : ProjectRoot;
var path = create
? EditorUtility.SaveFilePanel("New CultCache store", directory, "GameData", "cc")
: EditorUtility.OpenFilePanel("Open CultCache store", directory, _directory ? "" : "cc");
@ -245,15 +241,22 @@ namespace GameCult.Unity.Caching.Editor
GUIUtility.ExitGUI();
}
// New writes an empty single-file store and never touches an existing path; directory stores and read-only
// opens are refused because neither can write a store on creation.
// New writes an empty single-file store outside Assets and never touches an existing path; directory stores and
// read-only opens are refused because neither writes a store on creation. Game data never lives under Assets:
// Unity would import and manage it, and CultCache exists so Unity does not manage game data.
private void OpenStore(string path, bool create)
{
var exists = File.Exists(path) || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path));
var insideAssets = InsideAssets(path);
string refusal = null;
if (create && (_readOnly || _directory)) refusal = "New creates writable single-file stores only; turn off Read Only and Directory. Nothing was created.";
else if (create && exists) refusal = path + " already exists; nothing was created or replaced. Use Open to edit it.";
else if (!create && !File.Exists(path)) refusal = "No store at " + path + ".";
if (create && (_readOnly || _directory))
refusal = "New creates writable single-file stores only; turn off Read Only and Directory. Nothing was created.";
else if (create && insideAssets)
refusal = "New refuses " + path + ": game data does not live under Assets, where Unity imports and manages files, and CultCache exists so Unity does not manage game data. Choose a folder outside " + Application.dataPath + ". Nothing was created.";
else if (create && exists)
refusal = path + " already exists; nothing was created or replaced. Use Open to edit it.";
else if (!create && !File.Exists(path))
refusal = "No store at " + path + ".";
if (refusal != null)
{
SetStatus(refusal, MessageType.Error);
@ -270,10 +273,18 @@ namespace GameCult.Unity.Caching.Editor
if (!File.Exists(path)) throw new IOException("the store wrote no file");
}
_inspector = new CultInspector(_cache);
var registry = _cache.Registry;
_model = new CultInspectorModel(registry,
(value, type) => CultDocumentMessagePackSerialization.SerializeUntyped(value, type, registry),
(type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry));
_inspector = new CultInspector(_model);
_path = path;
EditorPrefs.SetString(LastPathKey, path);
SetStatus((create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info);
var opened = (create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : ".");
if (insideAssets)
SetStatus(opened + " This store is under Assets, where Unity imports and manages files; game data belongs outside Assets.", MessageType.Warning);
else
SetStatus(opened, MessageType.Info);
}
catch (Exception exception)
{
@ -282,15 +293,36 @@ namespace GameCult.Unity.Caching.Editor
}
}
private static bool InsideAssets(string path)
{
var full = FullPathOrNull(path);
var assets = FullPathOrNull(Application.dataPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return full != null && assets != null &&
(full.Equals(assets, StringComparison.OrdinalIgnoreCase) ||
full.StartsWith(assets + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
}
private static string FullPathOrNull(string path)
{
try
{
return Path.GetFullPath(path);
}
catch (Exception)
{
return null;
}
}
private void CloseStore()
{
_cache?.Dispose();
_cache = null;
_model = null;
_inspector = null;
_edit = null;
_records = Array.Empty<CultStoredDocument>();
_selectedKey = null;
_editSource = null;
_editCopy = null;
}
private bool ConfirmDiscard()
@ -314,17 +346,14 @@ namespace GameCult.Unity.Caching.Editor
private void Duplicate(CultStoredDocument record)
{
var type = record.Descriptor.DocumentType;
Run("Duplicated " + CultInspector.RecordLabel(record) + ".", () =>
_selectedKey = _cache.UpsertAsync(type, Clone(record.Document, type)).GetAwaiter().GetResult().Value);
Run("Duplicated " + CultInspectorModel.RecordLabel(record) + ".", () =>
_selectedKey = _cache.UpsertAsync(type, _model.Clone(record.Document, type)).GetAwaiter().GetResult().Value);
GUIUtility.ExitGUI();
}
private object Clone(object document, Type type) =>
CultDocumentMessagePackSerialization.DeserializeUntyped(type, CultDocumentMessagePackSerialization.SerializeUntyped(document, type, _cache.Registry), _cache.Registry);
private void Delete(CultStoredDocument record)
{
var label = CultInspector.RecordLabel(record);
var label = CultInspectorModel.RecordLabel(record);
if (!EditorUtility.DisplayDialog("CultCache Studio", "Delete " + label + "?", "Delete", "Cancel")) return;
Run("Deleted " + label + ".", () =>
{

View file

@ -2,7 +2,6 @@
"name": "GameCult.Unity.Caching.Editor",
"rootNamespace": "GameCult.Unity.Caching.Editor",
"references": [
"GameCult.Unity.Caching",
"GameCult.CultLib"
],
"includePlatforms": [

View file

@ -4,56 +4,89 @@
store, browse its documents, edit them, save.
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`.
and flush. `CultInspectorModel` (in `GameCult.Caching`) owns what an inspector
shows and which edits it may make. The Studio window owns selection, the
toolbar and IMGUI widgets. It references `GameCult.Caching` and
`GameCult.Caching.MessagePack` at compile time through the
`org.gamecult.cultlib` package (assembly `GameCult.CultLib`).
## Use
1. Add this package; it depends on `org.gamecult.cultlib` and
`org.gamecult.cultmath`.
1. Add this package; it depends on `org.gamecult.cultlib`.
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.
3. `Open` a `.cc` store. `Directory` opens a directory store (a manifest beside
its `.records` folder; an existing one is detected without the toggle).
`Read Only` opens the store read-only. `New` creates an empty writable
single-file store; it is disabled while `Read Only` or `Directory` is on and
refuses a path that already exists.
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.
Game data stores do not live under the project's `Assets` folder: Unity would
import and manage them, and CultCache exists so Unity does not manage game
data. `New` refuses a path under `Assets`, `Open` warns about one, and the file
dialogs start in the last store's folder or the project root.
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.
Opening a store never writes it, directory stores included. Closing or
reloading scripts drops unsaved edits.
## Inspection Model
`CultInspectorModel` is engine-free and lives beside the inspector attributes
in `GameCult.Caching`. Given a registry and the store's document codec it gives
the member list of a type (registry catalog slots, `CultInspector*` metadata,
whether a member is assignable), the shape of any value (string, integer,
float, bool, enum, composite value rebuilt through its constructor,
`CultRecordRef<T>` with its candidate records, rank-1 list or array,
dictionary, `[Union]` with only its declared subtypes, nested keyed object, or
unsupported with a reason), drawer claim resolution
(`CultInspectorDrawerClaims`), dictionary key identity and refusal (null keys,
empty record references and duplicates), and edits that work on a copy
(`CultInspectorEdit`): a refused upsert leaves the cached object unchanged. The
Studio is one lowering of that model and owns only widgets and layout; a
runtime CultUI panel needs only its own renderer and drawers to show and edit
the same documents under the same rules.
## 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.
Built in: every model shape above. Composite values of scalars (`float2/3/4`,
`int2`, `double2/3`, `bool2`, `quaternion`, `Color32`) draw as one row;
`rect` folds out to its corners. Unity object references draw as object
fields. A value no drawer claims and no shape covers shows a red error row, as
does a multi-dimensional array.
A project adds or overrides a drawer with an editor class:
A project adds or overrides a drawer with an editor class. `Claimed` is either
a value type (an open generic definition claims every closed form) or an
attribute type, which claims every member carrying it. Attribute claims win
over type claims, which win over built-in drawing. A claim two drawers make is
used by neither: both are logged and the member shows the error row. A drawer
that throws draws an error row and leaves the value unchanged.
```csharp
using System;
using System.Reflection;
using GameCult.Unity.Caching;
using GameCult.Caching;
using GameCult.Unity.Caching.Editor;
using UnityEditor;
using UnityEngine;
[CultInspectorDrawer(typeof(CultMath.float3))]
public sealed class Float3ColorDrawer : ICultInspectorDrawer
public sealed class ColorAttribute : Attribute
{
public object Draw(string label, object value, MemberInfo member)
}
[CultInspectorDrawer(typeof(ColorAttribute))]
public sealed class ColorDrawer : ICultInspectorDrawer
{
public object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member)
{
if (type != typeof(CultMath.float3))
return inspector.DrawDefault(label, type, value, 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);
@ -61,14 +94,18 @@ public sealed class Float3ColorDrawer : ICultInspectorDrawer
}
```
`MemberType` may be an open generic definition to claim every closed form. A
project drawer wins over the built-in one for its type.
`Draw` receives the value's declared type and the member it belongs to, also
for list elements and dictionary keys and values, so an attribute drawer
checks the type it is handed. `inspector.DrawDefault` hands a value back to
built-in drawing; `inspector.DrawValue` draws a sub-value with claims applied.
## Inspector Annotations
The annotations live in `GameCult.Caching`, so headless shared models carry
them without referencing Unity.
```csharp
using GameCult.Caching;
using GameCult.Unity.Caching;
using MessagePack;
[MessagePackObject]
@ -89,7 +126,7 @@ public sealed class ItemData
public string Notes = string.Empty;
[Key(3)]
[CultInspectorAssetPath(typeof(UnityEngine.Texture2D))]
[CultInspectorAssetPath]
public string IconPath = string.Empty;
}
```
@ -100,7 +137,8 @@ public sealed class ItemData
- `CultInspectorOrder`
- `CultInspectorTextArea`
- `CultInspectorRange`
- `CultInspectorAssetPath`
- `CultInspectorAssetPath` (optional engine asset `Type`, named by the
consumer; the Studio uses it when it is a `UnityEngine.Object` type)
- `CultInspectorDrawer` (on drawer classes)
CultMesh collaboration should feed the same CultCache mutation surface rather

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.caching.unity",
"displayName": "GameCult CultCache Unity Tools",
"version": "1.1.0",
"version": "1.2.0",
"unity": "2021.3",
"description": "Unity editor tooling for inspecting and editing CultCache .cc stores.",
"keywords": [
@ -10,8 +10,7 @@
"Data"
],
"dependencies": {
"org.gamecult.cultlib": "1.0.57",
"org.gamecult.cultmath": "0.1.2"
"org.gamecult.cultlib": "1.0.57"
},
"license": "MIT",
"category": "Scripting"