Skip to content

Instantly share code, notes, and snippets.

@MattRix
Last active March 7, 2025 12:35
Show Gist options
  • Save MattRix/be5613e48ce701608b235f594244408c to your computer and use it in GitHub Desktop.
Save MattRix/be5613e48ce701608b235f594244408c to your computer and use it in GitHub Desktop.
Generate ScriptableObjects that have named and typed references to assets in their folder

Place an AssetLibrary scriptableobject in a folder, and press the "Regenerate" button. It'll populate itself with named and typed references to assets in the same folder. Assets in subfolders will be brought in as arrays, unless you place another AssetLibrary in a subfolder, in which case you'll get a sort of nested AssetLibrary structure.

using System.IO;
using System.Text;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif
[CreateAssetMenu(menuName = "AssetLibrary")]
public class AssetLibrary : ScriptableObject
{
#if UNITY_EDITOR
[MenuItem("Assets/Generate Asset Library", true)]
private static bool ValidateGenerateAssetLibrary() => Selection.activeObject is AssetLibrary;
[MenuItem("Assets/Generate Asset Library", false, 1)]
private static void GenerateAssetLibrary()
{
var assetLib = Selection.activeObject as AssetLibrary;
if (assetLib == null)
return;
GenerateClass(assetLib);
}
[ContextMenu("Regenerate")]
protected void Regenerate()
{
GenerateClass(this);
}
// Helper classes.
private class AssetEntry
{
public string assetPath;
public string assetName;
public string fieldName;
public string typeName;
}
private class SubfolderEntry
{
public string folderPath; // e.g., "Assets/MyFolder/Subfolder"
public string folderName; // e.g., "Subfolder"
public string fieldName; // sanitized field name
public bool isLibraryReference; // true if subfolder contains an AssetLibrary subclass
// For library reference:
public string libraryAssetPath;
public string libraryTypeName;
// For non-library array:
public List<AssetEntry> childAssets;
public string arrayElementType;
}
// Returns a proper type name:
// If the type is in UnityEngine (i.e. "UnityEngine.Button"), then use the fully qualified name.
// Otherwise, keep the short name since "using UnityEngine;" is present.
private static string GetProperTypeName(System.Type type)
{
if (type == null)
return "UnityEngine.Object";
if (!string.IsNullOrEmpty(type.Namespace) && type.Namespace != "UnityEngine")
return type.FullName;
return type.Name;
}
// Scans the asset library folder to build our entries.
private static void ScanAssetLibraryFolder(AssetLibrary assetLib, out List<AssetEntry> directEntries, out List<SubfolderEntry> subfolderEntries)
{
directEntries = new List<AssetEntry>();
subfolderEntries = new List<SubfolderEntry>();
string assetPath = AssetDatabase.GetAssetPath(assetLib);
string folderPath = Path.GetDirectoryName(assetPath);
string projectFolder = Directory.GetParent(Application.dataPath).FullName;
string folderSystemPath = Path.Combine(projectFolder, folderPath);
// Process direct assets (non-recursive)
string[] files = Directory.GetFiles(folderSystemPath);
foreach (var file in files)
{
if (file.EndsWith(".meta"))
continue;
string relativePath = file.Replace(projectFolder + Path.DirectorySeparatorChar, "").Replace("\\", "/");
if (relativePath.EndsWith(".cs") || relativePath.EndsWith(".js"))
continue;
if (relativePath == assetPath)
continue;
Object obj = AssetDatabase.LoadMainAssetAtPath(relativePath);
if (obj == null)
continue;
var entry = new AssetEntry
{
assetPath = relativePath,
assetName = Path.GetFileNameWithoutExtension(relativePath),
// Use GetProperTypeName to ensure proper type name (e.g., UnityEngine.UI.Button)
typeName = GetProperTypeName(obj.GetType()),
fieldName = SanitizeFieldName(Path.GetFileNameWithoutExtension(relativePath))
};
// For GameObjects, try to use its first non-Transform component.
if (obj is GameObject go)
{
foreach (Component comp in go.GetComponents<Component>())
{
if (comp == null)
continue;
if (comp is Transform)
continue;
entry.typeName = GetProperTypeName(comp.GetType());
break;
}
}
// Ensure field name uniqueness.
int duplicateCount = 1;
string originalFieldName = entry.fieldName;
while (directEntries.Exists(e => e.fieldName == entry.fieldName))
{
entry.fieldName = originalFieldName + "_" + duplicateCount;
duplicateCount++;
}
directEntries.Add(entry);
}
// Process immediate subfolders.
string[] subfolderPaths = AssetDatabase.GetSubFolders(folderPath);
foreach (var subfolder in subfolderPaths)
{
var subEntry = new SubfolderEntry();
subEntry.folderPath = subfolder;
subEntry.folderName = Path.GetFileName(subfolder);
subEntry.fieldName = SanitizeFieldName(subEntry.folderName);
string subfolderSystemPath = Path.Combine(projectFolder, subfolder);
string[] subFiles = Directory.GetFiles(subfolderSystemPath);
List<AssetEntry> childAssets = new List<AssetEntry>();
bool foundLibrary = false;
AssetEntry libraryEntry = null;
foreach (var file in subFiles)
{
if (file.EndsWith(".meta"))
continue;
string relativePath = file.Replace(projectFolder + Path.DirectorySeparatorChar, "").Replace("\\", "/");
if (relativePath.EndsWith(".cs") || relativePath.EndsWith(".js"))
continue;
Object obj = AssetDatabase.LoadMainAssetAtPath(relativePath);
if (obj == null)
continue;
// Check for an AssetLibrary subclass in this folder.
if (obj is AssetLibrary && obj.GetType() != typeof(AssetLibrary))
{
foundLibrary = true;
libraryEntry = new AssetEntry
{
assetPath = relativePath,
assetName = Path.GetFileNameWithoutExtension(relativePath),
typeName = GetProperTypeName(obj.GetType()),
fieldName = SanitizeFieldName(Path.GetFileNameWithoutExtension(relativePath))
};
break; // Use the first found library.
}
else
{
var childEntry = new AssetEntry
{
assetPath = relativePath,
assetName = Path.GetFileNameWithoutExtension(relativePath),
typeName = GetProperTypeName(obj.GetType()),
fieldName = SanitizeFieldName(Path.GetFileNameWithoutExtension(relativePath))
};
childAssets.Add(childEntry);
}
}
if (foundLibrary && libraryEntry != null)
{
subEntry.isLibraryReference = true;
subEntry.libraryAssetPath = libraryEntry.assetPath;
subEntry.libraryTypeName = libraryEntry.typeName;
}
else
{
subEntry.isLibraryReference = false;
subEntry.childAssets = childAssets;
string commonType = null;
foreach (var child in childAssets)
{
if (commonType == null)
commonType = child.typeName;
else if (commonType != child.typeName)
{
commonType = "UnityEngine.Object";
break;
}
}
subEntry.arrayElementType = commonType ?? "UnityEngine.Object";
}
subfolderEntries.Add(subEntry);
}
}
private static void GenerateClass(AssetLibrary assetLib)
{
// Determine the folder containing the assetLib.
string assetPath = AssetDatabase.GetAssetPath(assetLib);
string folderPath = Path.GetDirectoryName(assetPath);
string assetLibName = assetLib.name;
string classFileName = assetLibName + ".cs";
string classFilePath = Path.Combine(folderPath, classFileName);
// Scan the asset folder.
ScanAssetLibraryFolder(assetLib, out List<AssetEntry> directEntries, out List<SubfolderEntry> subfolderEntries);
// Generate the new subclass code.
StringBuilder sb = new StringBuilder();
sb.AppendLine("using UnityEngine;");
sb.AppendLine();
sb.AppendLine("public class " + assetLibName + " : AssetLibrary");
sb.AppendLine("{");
// Direct asset fields.
foreach (var entry in directEntries)
{
sb.AppendLine(" public " + entry.typeName + " " + entry.fieldName + ";");
}
// Subfolder fields.
foreach (var subEntry in subfolderEntries)
{
if (subEntry.isLibraryReference)
sb.AppendLine(" public " + subEntry.libraryTypeName + " " + subEntry.fieldName + ";");
else
sb.AppendLine(" public " + subEntry.arrayElementType + "[] " + subEntry.fieldName + ";");
}
sb.AppendLine("}");
string classCode = sb.ToString();
File.WriteAllText(classFilePath, classCode);
AssetDatabase.ImportAsset(classFilePath);
AssetDatabase.Refresh();
// Update the ScriptableObject to use the newly generated script.
MonoScript newScript = AssetDatabase.LoadAssetAtPath<MonoScript>(classFilePath);
if (newScript != null)
{
SerializedObject so = new SerializedObject(assetLib);
SerializedProperty scriptProp = so.FindProperty("m_Script");
scriptProp.objectReferenceValue = newScript;
so.ApplyModifiedProperties();
// Mark the asset dirty so Unity reimports it.
if(assetLib != null) EditorUtility.SetDirty(assetLib);
AssetDatabase.SaveAssets();
}
else
{
Debug.LogError("Failed to load generated script: " + classFilePath);
return;
}
// Instead of scheduling a delayCall that might get lost on reload,
// store the asset path persistently so we can process it on load.
EditorPrefs.SetString("PendingAssetLibraryHookup", assetPath);
}
// Made internal so our post-reload processor can call it.
internal static void HookupAssetReferences(string assetLibAssetPath)
{
// If still compiling, re-save the pending key and exit.
if (EditorApplication.isCompiling)
{
EditorPrefs.SetString("PendingAssetLibraryHookup", assetLibAssetPath);
return;
}
AssetLibrary assetLib = AssetDatabase.LoadAssetAtPath<AssetLibrary>(assetLibAssetPath);
if (assetLib == null)
{
Debug.Log("AssetLibrary not loaded yet; retrying hookup...");
EditorPrefs.SetString("PendingAssetLibraryHookup", assetLibAssetPath);
return;
}
// Re-scan the asset folder.
ScanAssetLibraryFolder(assetLib, out List<AssetEntry> directEntries, out List<SubfolderEntry> subfolderEntries);
SerializedObject so = new SerializedObject(assetLib);
// Hook up direct asset fields.
foreach (var entry in directEntries)
{
SerializedProperty prop = so.FindProperty(entry.fieldName);
if (prop != null)
{
Object assetObj = AssetDatabase.LoadAssetAtPath<Object>(entry.assetPath);
// For GameObjects, if the field expects a component type.
if (assetObj is GameObject go && entry.typeName != "GameObject")
{
System.Type compType = GetTypeByName(entry.typeName);
if (compType != null)
{
Component comp = go.GetComponent(compType);
if (comp != null)
assetObj = comp;
else
Debug.LogWarning($"GameObject '{go.name}' does not have a component of type '{entry.typeName}'");
}
}
prop.objectReferenceValue = assetObj;
}
}
// Hook up subfolder fields.
foreach (var subEntry in subfolderEntries)
{
SerializedProperty prop = so.FindProperty(subEntry.fieldName);
if (prop != null)
{
if (subEntry.isLibraryReference)
{
Object assetObj = AssetDatabase.LoadAssetAtPath<Object>(subEntry.libraryAssetPath);
prop.objectReferenceValue = assetObj;
}
else if (subEntry.childAssets != null)
{
prop.arraySize = subEntry.childAssets.Count;
for (int i = 0; i < subEntry.childAssets.Count; i++)
{
SerializedProperty elementProp = prop.GetArrayElementAtIndex(i);
Object assetObj = AssetDatabase.LoadAssetAtPath<Object>(subEntry.childAssets[i].assetPath);
elementProp.objectReferenceValue = assetObj;
}
}
}
}
so.ApplyModifiedProperties();
AssetDatabase.SaveAssets();
// Re-select the asset to force an inspector update.
Selection.activeObject = assetLib;
EditorGUIUtility.PingObject(assetLib);
Debug.Log("Asset Library references hooked up for: " + assetLibAssetPath);
}
// Helper to find a type by its simple name.
private static System.Type GetTypeByName(string typeName)
{
List<System.Type> candidates = new List<System.Type>();
foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies())
{
foreach (var type in assembly.GetTypes())
{
if (type.Name == typeName)
candidates.Add(type);
}
}
// Prefer a candidate with no namespace (top-level type).
foreach (var candidate in candidates)
{
if (string.IsNullOrEmpty(candidate.Namespace))
return candidate;
}
return candidates.Count > 0 ? candidates[0] : null;
}
private static string SanitizeFieldName(string name)
{
if (string.IsNullOrEmpty(name))
return "field";
StringBuilder sb = new StringBuilder();
if (!char.IsLetter(name[0]) && name[0] != '_')
sb.Append('_');
foreach (char c in name)
{
if (char.IsLetterOrDigit(c) || c == '_')
sb.Append(c);
else
sb.Append('_');
}
return sb.ToString();
}
#endif
#if UNITY_EDITOR
//note: this is a subclass of AssetLibrary so we can mark the Regenerate() method as protected
[CustomEditor(typeof(AssetLibrary), true)]
[CanEditMultipleObjects]
public class AssetLibraryEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
if (GUILayout.Button("Regenerate"))
{
(target as AssetLibrary).Regenerate();
}
}
}
#endif
}
#if UNITY_EDITOR
// This static class ensures that after a domain reload the pending hookup gets processed.
[InitializeOnLoad]
public static class AssetLibraryDelayedHookup
{
static AssetLibraryDelayedHookup()
{
EditorApplication.delayCall += ProcessPendingHookup;
}
private static void ProcessPendingHookup()
{
string pendingPath = EditorPrefs.GetString("PendingAssetLibraryHookup", "");
if (!string.IsNullOrEmpty(pendingPath))
{
// Clear the pending key before processing.
EditorPrefs.DeleteKey("PendingAssetLibraryHookup");
AssetLibrary.HookupAssetReferences(pendingPath);
}
}
}
#endif
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment