Skip to content

Commit 2fb4923

Browse files
authored
Merge pull request #190 from brunomikoski/feature/new-guid-processor
Guid Processor + A lot of more things
2 parents e42fc7e + 865d352 commit 2fb4923

18 files changed

+937
-141
lines changed

.github/instructions.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Scriptable Object Collection — Project Instructions
2+
3+
## Project Overview
4+
5+
This is a **Unity Package** (`com.brunomikoski.scriptableobjectcollection`) that improves the usability of ScriptableObjects by grouping them into collections with code generation, custom inspectors, and GUID-based referencing. It targets **Unity 6000.0+** and is distributed via UPM (Unity Package Manager).
6+
7+
## Language and Runtime
8+
9+
- **C# 9** (Unity's default for Unity 6). Do not use C# 10+ features (e.g., `global using`, file-scoped namespaces, raw string literals).
10+
- Target **.NET Standard 2.1** APIs only. Do not use APIs exclusive to .NET 5/6/7+.
11+
- `unsafe` code is **not allowed** (`allowUnsafeCode: false` in assembly definitions).
12+
- Use `is` and `is not` pattern matching, target-typed `new()`, and null-coalescing where appropriate — these are available in C# 9.
13+
14+
## Project Structure
15+
16+
```
17+
Scripts/
18+
Runtime/ → Runtime assembly (BrunoMikoski.ScriptableObjectCollection)
19+
Editor/ → Editor assembly (BrunoMikoski.ScriptableObjectCollection.Editor)
20+
Browser/ → Editor Browser sub-assembly
21+
Core/ → Settings, code generation, dropdowns
22+
CustomEditors/ → Custom Inspector editors
23+
Processors/ → Asset post-processors
24+
PropertyDrawers/ → Custom property drawers
25+
Generators/ → Static code generators
26+
Samples~/ → UPM samples (ignored by Unity unless imported)
27+
Documentation~/ → UPM documentation (ignored by Unity at runtime)
28+
```
29+
30+
- **Runtime code must never reference `UnityEditor`** unless wrapped in `#if UNITY_EDITOR` / `#endif`.
31+
- Editor-only logic in runtime files must always be inside `#if UNITY_EDITOR` blocks.
32+
- Assembly definitions (`.asmdef`) define compilation boundaries — do not add cross-assembly references without updating them.
33+
34+
## Code Style and Conventions
35+
36+
### Naming
37+
- **Namespace**: `BrunoMikoski.ScriptableObjectCollections` (with `s`) for all types. Sub-namespaces: `.Picker`.
38+
- **Private fields**: `camelCase`, no underscore prefix (e.g., `private LongGuid guid;`).
39+
- **Properties**: `PascalCase` (e.g., `public LongGuid GUID => guid;`). Acronyms stay uppercase (`GUID`, `SOC`).
40+
- **Constants**: `UPPER_SNAKE_CASE` for private string constants in editors (e.g., `ITEMS_PROPERTY_NAME`), `PascalCase` for public constants.
41+
- **Classes**: One class per file. File name matches class name.
42+
- **Interfaces**: Prefixed with `I` (e.g., `ISOCItem`).
43+
44+
### Formatting
45+
- 4-space indentation (no tabs).
46+
- Allman-style braces (opening brace on new line).
47+
- UTF-8 encoding **without BOM** for all `.cs` files.
48+
- Normalize line endings (handle both `\r\n` and `\n`).
49+
50+
### Patterns
51+
- Prefer `for` loops over `foreach` in hot paths and runtime code to avoid enumerator allocations.
52+
- Use `[SerializeField]` with `private` fields; avoid public serialized fields.
53+
- Use `[HideInInspector]` for serialized fields that shouldn't appear in the default inspector.
54+
- Use `[FormerlySerializedAs("oldName")]` when renaming serialized fields to preserve data.
55+
- Avoid LINQ in runtime hot paths (allocations). LINQ is acceptable in editor code.
56+
- Cache lookups in dictionaries when collections are iterated frequently (see `itemGuidToScriptableObject`, `itemNameToScriptableObject` patterns).
57+
- Always validate caches: when returning a cached value, verify it's still valid (not destroyed, GUID still matches) before trusting it. Evict stale entries.
58+
59+
### Unity-Specific
60+
- Use `ObjectUtility.SetDirty(obj)` instead of calling `EditorUtility.SetDirty` directly (it wraps the editor check).
61+
- Use `ScriptableObject.CreateInstance<T>()` to instantiate ScriptableObjects, never `new`.
62+
- Null-check Unity objects carefully: use `.IsNull()` extension or explicit `== null` (Unity overrides `==` for destroyed objects). Standard `is null` does **not** detect destroyed Unity objects.
63+
- Use `AssetDatabase` APIs only inside `#if UNITY_EDITOR` blocks or in Editor assemblies.
64+
- Use `TypeCache` in editor code for efficient type lookups instead of reflection-heavy alternatives.
65+
66+
## GUID System
67+
68+
- Items and collections use `LongGuid` (a 128-bit struct, two `long` values) — not Unity's `string` GUIDs.
69+
- GUID validation and uniqueness is enforced by `SOCItemGuidProcessor` (an asset postprocessor), not at item creation time.
70+
- When working with GUIDs: always check `guid.IsValid()` before using. Generate new GUIDs with `GenerateNewGUID()`.
71+
72+
## Code Generation
73+
74+
- Generated scripts use UTF-8 without BOM.
75+
- Line endings are normalized before writing.
76+
- Generated files use `partial class` and the `new` modifier where appropriate to avoid compiler warnings.
77+
78+
## PR and Review Guidelines
79+
80+
- Keep runtime and editor changes clearly separated.
81+
- Any new serialized field rename must include `[FormerlySerializedAs]` for backward compatibility.
82+
- New editor UI should use UXML/UIToolkit where the existing editors do, but IMGUI is acceptable for property drawers.
83+
- Avoid introducing GC allocations in runtime `Matches()` or lookup methods — reuse collections (see `HashSet` reuse pattern in `CollectionItemQuery`).
84+
- Conditional compilation (`#if UNITY_EDITOR`, `#if ADDRESSABLES_ENABLED`) must be used correctly; do not assume optional packages are present.

Scripts/Editor/Core/CodeGenerationUtility.cs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public static bool CreateNewScript(
6161
if (File.Exists(Path.GetFullPath(finalFilePath)))
6262
return false;
6363

64-
using StreamWriter writer = new StreamWriter(finalFilePath);
64+
using StreamWriter writer = new StreamWriter(finalFilePath, false, new UTF8Encoding(false));
6565
int indentation = 0;
6666

6767
// First write the directives.
@@ -144,7 +144,7 @@ public static bool CreateNewScript(
144144
}
145145

146146
// Now create the script.
147-
string[] lines = codeTemplateText.Split("\r\n");
147+
string[] lines = codeTemplateText.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
148148
return CreateNewScript(fileName, parentFolder, nameSpace, directives, lines);
149149
}
150150

@@ -377,12 +377,12 @@ public static void GenerateIndirectAccessForCollectionItemType(string collection
377377
}
378378

379379
targetFileName += ExtensionNew;
380-
using (StreamWriter writer = new StreamWriter(targetFileName))
380+
using (StreamWriter writer = new StreamWriter(targetFileName, false, new UTF8Encoding(false)))
381381
{
382382
int indentation = 0;
383383
List<string> directives = new List<string>();
384384
directives.Add(typeof(ScriptableObjectCollection).Namespace);
385-
385+
386386
directives.Add(collectionNamespace);
387387
directives.Add("System");
388388
directives.Add("UnityEngine");
@@ -441,10 +441,10 @@ public static void GenerateStaticCollectionScript(ScriptableObjectCollection col
441441
}
442442

443443
finalFileName += ExtensionNew;
444-
using (StreamWriter writer = new StreamWriter(finalFileName))
444+
using (StreamWriter writer = new StreamWriter(finalFileName, false, new UTF8Encoding(false)))
445445
{
446446
int indentation = 0;
447-
447+
448448
List<string> directives = new List<string>();
449449
directives.Add(typeof(CollectionsRegistry).Namespace);
450450
directives.Add(collection.GetType().Namespace);
@@ -564,15 +564,20 @@ private static void WriteDirectAccessCollectionStatic(ScriptableObjectCollection
564564

565565
Type itemType = collection.GetItemType();
566566
bool writeAsPartial = SOCSettings.Instance.GetWriteAsPartialClass(collection);
567-
bool hasBaseTypeCollection = false;
568567

569-
if (itemType != null && itemType.BaseType != null)
568+
// The 'new' modifier is only needed when our item type extends another type that has a generated
569+
// Values property. That only happens when a collection exists for the *exact* base type (not
570+
// derived types) and uses partial generation. GetCollectionsByItemType returns derived
571+
// collections too (e.g. CarCollection when querying Vehicle), so we must filter.
572+
bool baseTypeHasValuesProperty = false;
573+
if (itemType?.BaseType != null)
570574
{
571575
List<ScriptableObjectCollection> baseCollections = CollectionsRegistry.Instance.GetCollectionsByItemType(itemType.BaseType);
572-
hasBaseTypeCollection = baseCollections != null && baseCollections.Count > 0;
576+
baseTypeHasValuesProperty = baseCollections != null && baseCollections.Any(c =>
577+
c.GetItemType() == itemType.BaseType && SOCSettings.Instance.GetWriteAsPartialClass(c));
573578
}
574579

575-
bool addNewModifier = writeAsPartial && hasBaseTypeCollection;
580+
bool addNewModifier = writeAsPartial && baseTypeHasValuesProperty;
576581

577582
AppendLine(writer, indentation, $"public {(addNewModifier ? "new " : string.Empty)}static {collection.GetType().FullName} {PublicValuesName}");
578583

Scripts/Editor/Core/CollectionItemDropdown.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,17 @@ protected override void ItemSelected(AdvancedDropdownItem item)
162162
if (item.name.Equals(CREATE_NEW_TEXT, StringComparison.OrdinalIgnoreCase))
163163
{
164164
ScriptableObjectCollection collection = collections.First();
165-
ScriptableObject newItem = CollectionCustomEditor.AddNewItem(collection, itemType);
166-
callback.Invoke(newItem);
167-
168-
InvokeOnSelectCallback(previousValue, newItem);
165+
TypeCache.TypeCollection types = TypeCache.GetTypesDerivedFrom(itemType);
166+
if (types.Count == 0)
167+
{
168+
ScriptableObject newItem = CollectionCustomEditor.AddNewItem(collection, itemType);
169+
callback.Invoke(newItem);
170+
InvokeOnSelectCallback(previousValue, newItem);
171+
}
172+
else
173+
{
174+
Selection.activeObject = collection;
175+
}
169176

170177
return;
171178
}

Scripts/Editor/Core/SOCSettings.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public bool GetUseBaseClassForItem(ScriptableObjectCollection collection)
201201
return GetOrCreateCollectionSettings(collection).UseBaseClassForItems;
202202
}
203203

204-
public void SetUsingBaseClassForItems(ScriptableObjectCollection collection, bool useBaseClass)
204+
public void SetUseBaseClassForItems(ScriptableObjectCollection collection, bool useBaseClass)
205205
{
206206
CollectionSettings settings = GetOrCreateCollectionSettings(collection);
207207
settings.SetUseBaseClassForItems(useBaseClass);
@@ -299,5 +299,6 @@ public void SaveCollectionSettings(ScriptableObjectCollection collection, bool f
299299
CollectionSettings settings = GetOrCreateCollectionSettings(collection);
300300
settings.Save();
301301
}
302+
302303
}
303-
}
304+
}

Scripts/Editor/CustomEditors/CollectionCustomEditor.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ namespace BrunoMikoski.ScriptableObjectCollections
2222
[CustomEditor(typeof(ScriptableObjectCollection), true)]
2323
public class CollectionCustomEditor : Editor
2424
{
25+
private const string COLLECTION_EDITOR_UXML_PATH = "Packages/com.brunomikoski.scriptableobjectcollection/Editor/UXML/CollectionCustomEditorTreeAsset.uxml";
26+
private const string COLLECTION_ITEM_UXML_PATH = "Packages/com.brunomikoski.scriptableobjectcollection/Editor/UXML/CollectionItemTreeAsset.uxml";
2527
private const string WAITING_FOR_SCRIPT_TO_BE_CREATED_KEY = "WaitingForScriptTobeCreated";
2628
private static ScriptableObject LAST_ADDED_COLLECTION_ITEM;
2729

@@ -113,6 +115,15 @@ public static ScriptableObject AddNewItem(ScriptableObjectCollection collection,
113115
public override VisualElement CreateInspectorGUI()
114116
{
115117
VisualElement root = new();
118+
EnsureVisualTreeAssetsAreLoaded();
119+
if (visualTreeAsset == null || collectionItemVisualTreeAsset == null)
120+
{
121+
root.Add(new HelpBox(
122+
"Could not load Collection editor UI assets. Reimport 'com.brunomikoski.scriptableobjectcollection' package or restore missing UXML files.",
123+
HelpBoxMessageType.Error));
124+
return root;
125+
}
126+
116127
visualTreeAsset.CloneTree(root);
117128

118129
collection = (ScriptableObjectCollection)target;
@@ -197,7 +208,7 @@ public override VisualElement CreateInspectorGUI()
197208
useBaseClassForItems.value = SOCSettings.Instance.GetUseBaseClassForItem(collection);
198209
useBaseClassForItems.RegisterValueChangedCallback(evt =>
199210
{
200-
SOCSettings.Instance.SetUsingBaseClassForItems(collection, evt.newValue);
211+
SOCSettings.Instance.SetUseBaseClassForItems(collection, evt.newValue);
201212
});
202213

203214
TextField staticFileNameTextField = root.Q<TextField>("static-filename-textfield");
@@ -254,12 +265,28 @@ public override VisualElement CreateInspectorGUI()
254265
ToolbarSearchField toolbarSearchField = root.Q<ToolbarSearchField>();
255266
toolbarSearchField.RegisterValueChangedCallback(OnSearchInputChanged);
256267

268+
IMGUIContainer additionalIMGUIContainer = new IMGUIContainer(DrawAdditionalIMGUI);
269+
root.Add(additionalIMGUIContainer);
270+
257271

258272
root.schedule.Execute(OnVisualTreeCreated).ExecuteLater(100);
259273

260274
return root;
261275
}
262276

277+
private void EnsureVisualTreeAssetsAreLoaded()
278+
{
279+
if (visualTreeAsset == null)
280+
visualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(COLLECTION_EDITOR_UXML_PATH);
281+
282+
if (collectionItemVisualTreeAsset == null)
283+
collectionItemVisualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(COLLECTION_ITEM_UXML_PATH);
284+
}
285+
286+
protected virtual void DrawAdditionalIMGUI()
287+
{
288+
}
289+
263290
private void UpdateAutomaticallyLoaded()
264291
{
265292
CollectionsRegistry.Instance.UpdateAutoSearchForCollections();
@@ -394,7 +421,7 @@ protected virtual void OnEnable()
394421
collection.Clear();
395422
}
396423

397-
if (!CollectionsRegistry.Instance.IsKnowCollection(collection))
424+
if (!CollectionsRegistry.Instance.IsKnownCollection(collection))
398425
CollectionsRegistry.Instance.ReloadCollections();
399426

400427
// Need to cache this before the reorderable list is created, because it affects how the list is displayed.

Scripts/Editor/Processors/CollectionsAssetsPostProcessor.cs

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,29 +24,6 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse
2424

2525
Type type = AssetDatabase.GetMainAssetTypeAtPath(importedAssetPath);
2626

27-
if (typeof(ScriptableObject).IsAssignableFrom(type))
28-
{
29-
ScriptableObject collectionItem =
30-
AssetDatabase.LoadAssetAtPath<ScriptableObject>(importedAssetPath);
31-
32-
if (collectionItem == null)
33-
{
34-
continue;
35-
}
36-
37-
if (collectionItem is not ISOCItem socItem)
38-
{
39-
continue;
40-
}
41-
42-
if (!CollectionsRegistry.Instance.HasUniqueGUID(socItem))
43-
{
44-
socItem.GenerateNewGUID();
45-
socItem.ClearCollection();
46-
Debug.LogWarning($"Item {socItem} GUID was not unique, generating a new one and clearing the collection");
47-
}
48-
}
49-
5027
if (typeof(ScriptableObjectCollection).IsAssignableFrom(type))
5128
{
5229
ScriptableObjectCollection collection =
@@ -62,7 +39,7 @@ static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAsse
6239
Debug.LogWarning($"Collection {collection} GUID was not unique, generating a new one, and clearing the items");
6340
}
6441

65-
if (!CollectionsRegistry.Instance.IsKnowCollection(collection))
42+
if (!CollectionsRegistry.Instance.IsKnownCollection(collection))
6643
{
6744
RefreshRegistry();
6845
Debug.Log($"New collection found on the Project {collection.name}, refreshing the Registry");
@@ -93,4 +70,4 @@ static void OnAfterScriptsReloading()
9370
RefreshRegistryAfterRecompilation = false;
9471
}
9572
}
96-
}
73+
}

0 commit comments

Comments
 (0)